Skip to main content

orch/engine/
dependencies.rs

1//! Sync GitHub blocked-by links into SQLite and hold tasks until those links are done.
2//!
3//! Native `blocked_by` is the source of truth when the API answers. The issue
4//! body is used only when that API is unavailable. A failed read leaves stored
5//! rows in place.
6
7use crate::backends::{BlockedByFetch, ExternalBackend, ExternalId, PullRequestCompletion};
8use crate::channels::notification::TaskNotification;
9use crate::channels::transport::Transport;
10use crate::store::{
11    blocker_failure_kind, blocker_is_satisfied, dep_label, BlockerSnapshot, DepSource,
12    NewDependency, ObservedPullRequest, TaskStore,
13};
14use std::collections::{HashMap, HashSet};
15
16const DEP_SYNC_TTL_SECS: i64 = 45;
17
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct DepRef {
20    pub repo: String,
21    pub number: i64,
22}
23
24#[derive(Debug, Clone)]
25pub struct SyncOutcome {
26    pub cache: bool,
27}
28
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct DepNotice {
31    pub repo: String,
32    pub issue_number: i64,
33    pub comment: String,
34    pub notify: bool,
35    pub summary: String,
36    pub task_id: i64,
37    pub title: String,
38}
39
40/// Parse `Depends on` and `Blocked by` lines.
41///
42/// Bare `#12` uses `default_repo`. `owner/repo#12` keeps that repository.
43/// Lines inside fenced code blocks are ignored
44pub fn parse_body_dependencies(body: &str, default_repo: &str) -> Vec<DepRef> {
45    let mut out = Vec::new();
46    let mut seen = HashSet::new();
47    let mut in_fence = false;
48    for line in body.lines() {
49        let trimmed = line.trim();
50        if trimmed.starts_with("```") {
51            in_fence = !in_fence;
52            continue;
53        }
54        if in_fence {
55            continue;
56        }
57        let Some(rest) = dependency_line_rest(trimmed) else {
58            continue;
59        };
60        for dep in extract_refs(rest, default_repo) {
61            if seen.insert((dep.repo.clone(), dep.number)) {
62                out.push(dep);
63            }
64        }
65    }
66    out
67}
68
69fn dependency_line_rest(trimmed: &str) -> Option<&str> {
70    let lower = trimmed.to_ascii_lowercase();
71    let prefix = if lower.starts_with("depends on") {
72        "depends on".len()
73    } else if lower.starts_with("blocked by") {
74        "blocked by".len()
75    } else {
76        return None;
77    };
78    let rest = trimmed[prefix..]
79        .trim_start()
80        .trim_start_matches(':')
81        .trim();
82    if rest.is_empty() {
83        None
84    } else {
85        Some(rest)
86    }
87}
88
89/// One `--depends-on` token: a bare issue number, `#12`, or `owner/repo#12`
90pub fn parse_depends_on_token(token: &str, default_repo: &str) -> Option<DepRef> {
91    let trimmed = token.trim().trim_matches(',');
92    if trimmed.is_empty() {
93        return None;
94    }
95    if let Ok(number) = trimmed.parse::<i64>() {
96        if number > 0 && !default_repo.is_empty() {
97            return Some(DepRef {
98                repo: default_repo.to_string(),
99                number,
100            });
101        }
102    }
103    extract_refs(trimmed, default_repo).into_iter().next()
104}
105
106fn extract_refs(text: &str, default_repo: &str) -> Vec<DepRef> {
107    let mut out = Vec::new();
108    let bytes = text.as_bytes();
109    let mut index = 0;
110    while index < bytes.len() {
111        if bytes[index] != b'#' {
112            index += 1;
113            continue;
114        }
115        let digits: String = text[index + 1..]
116            .chars()
117            .take_while(|ch| ch.is_ascii_digit())
118            .collect();
119        if digits.is_empty() {
120            index += 1;
121            continue;
122        }
123        let number: i64 = match digits.parse() {
124            Ok(number) if number > 0 => number,
125            _ => {
126                index += 1 + digits.len();
127                continue;
128            }
129        };
130        let repo = repo_before(text, index).unwrap_or(default_repo).to_string();
131        if !repo.is_empty() {
132            out.push(DepRef { repo, number });
133        }
134        index += 1 + digits.len();
135    }
136    out
137}
138
139fn repo_before(text: &str, hash_at: usize) -> Option<&str> {
140    let before = &text[..hash_at];
141    let mut start = hash_at;
142    let bytes = before.as_bytes();
143    while start > 0 {
144        let ch = bytes[start - 1] as char;
145        if ch.is_ascii_alphanumeric() || matches!(ch, '_' | '.' | '-' | '/') {
146            start -= 1;
147        } else {
148            break;
149        }
150    }
151    let candidate = &before[start..];
152    let mut parts = candidate.split('/');
153    let owner = parts.next().unwrap_or("");
154    let name = parts.next().unwrap_or("");
155    if parts.next().is_some() || owner.is_empty() || name.is_empty() {
156        return None;
157    }
158    if !owner
159        .chars()
160        .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '.' | '-'))
161        || !name
162            .chars()
163            .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '.' | '-'))
164    {
165        return None;
166    }
167    Some(candidate)
168}
169
170/// Replace one issue's dependency rows from GitHub, or from the body when GitHub cannot answer.
171pub async fn sync_issue_dependencies(
172    backend: &dyn ExternalBackend,
173    store: &TaskStore,
174    task_id: i64,
175    repo: &str,
176    issue_number: Option<i64>,
177    body: &str,
178) -> anyhow::Result<SyncOutcome> {
179    let Some(issue_number) = issue_number.filter(|number| *number > 0) else {
180        return Ok(SyncOutcome { cache: false });
181    };
182    let fetch = match backend.blocked_by(repo, issue_number).await {
183        Ok(fetch) => fetch,
184        Err(err) => BlockedByFetch::Failed(err.to_string()),
185    };
186    match fetch {
187        BlockedByFetch::Native(list) => {
188            for item in &list {
189                if item.state.is_empty() {
190                    continue;
191                }
192                store
193                    .note_blocker_issue_state(
194                        &item.repo,
195                        item.number,
196                        &item.state,
197                        item.state_reason.as_deref(),
198                    )
199                    .await?;
200            }
201            let rows = list
202                .iter()
203                .map(|item| NewDependency {
204                    repo: item.repo.clone(),
205                    number: item.number,
206                    source: DepSource::Github,
207                })
208                .collect::<Vec<_>>();
209            store.replace_dependencies(task_id, &rows).await?;
210            store.link_known_task(task_id, repo, issue_number).await?;
211            Ok(SyncOutcome { cache: true })
212        }
213        BlockedByFetch::Unavailable => {
214            let parsed = parse_body_dependencies(body, repo);
215            if parsed.is_empty() {
216                store.link_known_task(task_id, repo, issue_number).await?;
217                return Ok(SyncOutcome { cache: true });
218            }
219            for dep in &parsed {
220                if store
221                    .get_by_external_id(&dep.repo, &dep.number.to_string())
222                    .await?
223                    .is_none()
224                {
225                    if let Ok(Some((state, reason))) =
226                        backend.issue_close_state(&dep.repo, dep.number).await
227                    {
228                        store
229                            .note_blocker_issue_state(
230                                &dep.repo,
231                                dep.number,
232                                &state,
233                                reason.as_deref(),
234                            )
235                            .await?;
236                    }
237                }
238            }
239            let rows = parsed
240                .into_iter()
241                .map(|dep| NewDependency {
242                    repo: dep.repo,
243                    number: dep.number,
244                    source: DepSource::Body,
245                })
246                .collect::<Vec<_>>();
247            store.replace_dependencies(task_id, &rows).await?;
248            store.link_known_task(task_id, repo, issue_number).await?;
249            Ok(SyncOutcome { cache: true })
250        }
251        BlockedByFetch::Failed(message) => {
252            tracing::warn!(
253                task_id,
254                repo,
255                issue_number,
256                error = %message,
257                "dependency read failed, keeping stored rows"
258            );
259            Ok(SyncOutcome { cache: false })
260        }
261    }
262}
263
264/// Move `new` and `routed` tasks with open dependencies to `waiting`, and release
265/// `waiting` tasks whose dependencies are done
266pub async fn apply_claim_gates(store: &TaskStore) -> anyhow::Result<()> {
267    let tasks = store.list_schedulable().await?;
268    for task in tasks {
269        store.block_claim_if_waiting(task.id).await?;
270    }
271    Ok(())
272}
273
274/// Refresh blocked-by links for schedulable tasks, then hold or release them.
275///
276/// A successful read is cached for 45 seconds so a 10 second tick does not
277/// fetch every task every time. Sync from ingest calls `sync_issue_dependencies`
278/// directly and does not use this cache
279pub async fn reconcile_dependencies(
280    backend: &dyn ExternalBackend,
281    store: &TaskStore,
282    transport: Option<&Transport>,
283) -> anyhow::Result<()> {
284    let tasks = store.list_schedulable().await?;
285    let now = chrono::Utc::now().timestamp();
286    for task in &tasks {
287        let Some(number) = task
288            .external_id
289            .as_deref()
290            .and_then(|id| id.parse::<i64>().ok())
291            .filter(|number| *number > 0)
292        else {
293            continue;
294        };
295        let key = format!("dep_synced_at:{}", task.id);
296        if let Ok(Some(prev)) = store.kv_get(&key).await {
297            if let Ok(prev) = prev.parse::<i64>() {
298                if now.saturating_sub(prev) < DEP_SYNC_TTL_SECS {
299                    continue;
300                }
301            }
302        }
303        match sync_issue_dependencies(
304            backend,
305            store,
306            task.id,
307            &task.repo,
308            Some(number),
309            &task.body,
310        )
311        .await
312        {
313            Ok(outcome) if outcome.cache => {
314                if let Err(err) = store.kv_set(&key, &now.to_string()).await {
315                    tracing::warn!(task_id = task.id, err = %err, "failed to cache dependency sync time");
316                }
317            }
318            Ok(_) => {}
319            Err(err) => {
320                tracing::warn!(task_id = task.id, repo = %task.repo, err = %err, "dependency sync failed");
321            }
322        }
323    }
324    if let Err(err) = refresh_blocker_snapshots(backend, store).await {
325        tracing::warn!(err = %err, "blocker snapshot refresh failed");
326    }
327    apply_claim_gates(store).await?;
328    emit_dependency_notices(backend, store, transport).await?;
329    Ok(())
330}
331
332/// Read each blocker's issue and pull request, then store the snapshot the claim gate reads.
333///
334/// A backend that returns no issue state and no pull request number leaves the
335/// stored snapshot in place. A known pull request that cannot be read is stored
336/// as open and unmerged
337pub async fn refresh_blocker_snapshots(
338    backend: &dyn ExternalBackend,
339    store: &TaskStore,
340) -> anyhow::Result<()> {
341    let edges = store.list_dependency_edges().await?;
342    let mut seen = HashSet::new();
343    for edge in edges {
344        if !seen.insert((edge.depends_on_repo.clone(), edge.depends_on_number)) {
345            continue;
346        }
347        if let Err(err) = refresh_one_blocker(
348            backend,
349            store,
350            &edge.depends_on_repo,
351            edge.depends_on_number,
352        )
353        .await
354        {
355            tracing::warn!(
356                repo = %edge.depends_on_repo,
357                number = edge.depends_on_number,
358                err = %err,
359                "blocker snapshot refresh failed"
360            );
361        }
362    }
363    Ok(())
364}
365
366async fn refresh_one_blocker(
367    backend: &dyn ExternalBackend,
368    store: &TaskStore,
369    repo: &str,
370    number: i64,
371) -> anyhow::Result<()> {
372    // A satisfied blocker is terminal: the issue closed as completed, or its pull request
373    // merged into the default branch. Re-reading it cost four REST calls per tick for an
374    // answer that cannot change, and once the hourly quota dropped under the reserve the
375    // throttle refused the issue ingest with it. A reopen still lands here through
376    // `note_blocker_issue_state`, which `sync_issue_dependencies` calls with live state
377    if store
378        .blocker_snapshot(repo, number)
379        .await?
380        .is_some_and(|snap| snap.is_satisfied())
381    {
382        return Ok(());
383    }
384    let close = match backend.issue_close_state(repo, number).await {
385        Ok(state) => state,
386        Err(err) => {
387            tracing::warn!(repo, number, err = %err, "issue close state read failed");
388            None
389        }
390    };
391    let task = store.get_by_external_id(repo, &number.to_string()).await?;
392    let pr_number = task
393        .as_ref()
394        .and_then(|row| row.pr_number)
395        .filter(|n| *n > 0);
396    if close.is_none() && pr_number.is_none() {
397        return Ok(());
398    }
399    let (issue_state, reason) = match close {
400        Some((state, reason)) => (state, reason),
401        None => match store.blocker_snapshot(repo, number).await? {
402            Some(snap) => (snap.issue_state, snap.state_reason),
403            None => ("open".to_string(), None),
404        },
405    };
406    let observed = if let Some(pr) = pr_number {
407        let completion = match backend.pull_request_completion(repo, i64::from(pr)).await {
408            Ok(value) => value,
409            Err(err) => {
410                tracing::warn!(repo, number, pr, err = %err, "pull request completion read failed");
411                None
412            }
413        };
414        Some(observe_completion(completion))
415    } else {
416        None
417    };
418    let snap = BlockerSnapshot::observe(&issue_state, reason.as_deref(), observed.as_ref());
419    store.save_blocker_snapshot(repo, number, &snap).await
420}
421
422fn observe_completion(completion: Option<PullRequestCompletion>) -> ObservedPullRequest {
423    match completion {
424        Some(done) => ObservedPullRequest {
425            merged: done.merged,
426            base_is_default: !done.default_branch.is_empty()
427                && done.base_ref == done.default_branch,
428            approved: done.approved,
429            closed_unmerged: done.closed && !done.merged,
430        },
431        None => ObservedPullRequest {
432            merged: false,
433            base_is_default: false,
434            approved: false,
435            closed_unmerged: false,
436        },
437    }
438}
439
440/// True when dispatch must not start this task. A check error also skips dispatch
441pub async fn dispatch_is_waiting(store: &TaskStore, repo: &str, external_id: &str) -> bool {
442    let id = match store.resolve_id_by_external(repo, external_id).await {
443        Ok(Some(id)) => id,
444        Ok(None) => return false,
445        Err(err) => {
446            tracing::warn!(
447                task_id = external_id,
448                err = %err,
449                "dependency check failed, skipping dispatch"
450            );
451            return true;
452        }
453    };
454    match store.block_claim_if_waiting(id).await {
455        Ok(Some(blockers)) => {
456            tracing::info!(
457                task_id = external_id,
458                blockers = %blockers.join(", "),
459                "skipping dispatch, waiting on a dependency"
460            );
461            true
462        }
463        Ok(None) => false,
464        Err(err) => {
465            tracing::warn!(
466                task_id = external_id,
467                err = %err,
468                "dependency check failed, skipping dispatch"
469            );
470            true
471        }
472    }
473}
474
475/// Stop a manual route, run, or retry while dependencies are open.
476pub async fn refuse_if_waiting(store: &TaskStore, task_id: i64) -> anyhow::Result<()> {
477    if let Some(blockers) = store.block_claim_if_waiting(task_id).await? {
478        anyhow::bail!("task {task_id} is waiting on {}", blockers.join(", "));
479    }
480    Ok(())
481}
482
483pub async fn emit_dependency_notices(
484    backend: &dyn ExternalBackend,
485    store: &TaskStore,
486    transport: Option<&Transport>,
487) -> anyhow::Result<Vec<DepNotice>> {
488    let mut notices = Vec::new();
489    notices.extend(cycle_notices(store).await?);
490    notices.extend(failure_notices(store).await?);
491    for notice in &notices {
492        if notice.issue_number > 0 && !notice.comment.is_empty() {
493            let id = ExternalId(notice.issue_number.to_string());
494            if let Err(err) = backend
495                .post_comment_in_repo(&notice.repo, &id, &notice.comment)
496                .await
497            {
498                tracing::warn!(
499                    repo = %notice.repo,
500                    issue = notice.issue_number,
501                    err = %err,
502                    "failed to comment on dependency notice"
503                );
504            }
505        }
506        if notice.notify {
507            if let Some(transport) = transport {
508                transport.push_notification(TaskNotification {
509                    task_id: if notice.issue_number > 0 {
510                        notice.issue_number.to_string()
511                    } else {
512                        notice.task_id.to_string()
513                    },
514                    title: notice.title.clone(),
515                    status: "failed".to_string(),
516                    agent: "orch".to_string(),
517                    duration_seconds: 0.0,
518                    summary: notice.summary.clone(),
519                    repo: Some(notice.repo.clone()),
520                    notify_target: None,
521                    pr_number: None,
522                });
523            }
524        }
525    }
526    Ok(notices)
527}
528
529async fn cycle_notices(store: &TaskStore) -> anyhow::Result<Vec<DepNotice>> {
530    let edges = store.list_dependency_edges().await?;
531    let mut snap_cache: HashMap<(String, i64), Option<BlockerSnapshot>> = HashMap::new();
532    let mut adj: HashMap<String, Vec<String>> = HashMap::new();
533    let mut issue_for_node: HashMap<String, (String, i64, i64, String)> = HashMap::new();
534
535    for edge in &edges {
536        let waiter = match edge
537            .task_external_id
538            .as_deref()
539            .and_then(|id| id.parse::<i64>().ok())
540        {
541            Some(number) if number > 0 => format!("{}#{}", edge.task_repo, number),
542            _ => format!("task:{}", edge.task_id),
543        };
544        if let Some(number) = edge
545            .task_external_id
546            .as_deref()
547            .and_then(|id| id.parse::<i64>().ok())
548            .filter(|number| *number > 0)
549        {
550            issue_for_node.insert(
551                waiter.clone(),
552                (
553                    edge.task_repo.clone(),
554                    number,
555                    edge.task_id,
556                    edge.task_title.clone(),
557                ),
558            );
559        }
560        let blocker_node = format!("{}#{}", edge.depends_on_repo, edge.depends_on_number);
561        let snap = cached_snapshot(
562            store,
563            &mut snap_cache,
564            &edge.depends_on_repo,
565            edge.depends_on_number,
566        )
567        .await?;
568        if blocker_is_satisfied(snap.as_ref()) {
569            continue;
570        }
571        adj.entry(waiter).or_default().push(blocker_node);
572    }
573
574    let mut notices = Vec::new();
575    for cycle in find_cycles(&adj) {
576        let signature = {
577            let mut nodes = cycle.clone();
578            nodes.sort();
579            nodes.join(",")
580        };
581        let mut named = cycle.clone();
582        if let Some(first) = named.first().cloned() {
583            named.push(first);
584        }
585        let path = named.join(" -> ");
586        let comment = format!(
587            "Dependency cycle: {path}. Orch will not run these tasks until the cycle is removed.{}",
588            crate::engine::orch_footer()
589        );
590        for node in &cycle {
591            let Some((repo, number, task_id, title)) = issue_for_node.get(node) else {
592                continue;
593            };
594            let key = format!("dep_cycle_comment:{repo}#{number}:{signature}");
595            if store.kv_get(&key).await?.is_some() {
596                continue;
597            }
598            store.kv_set(&key, "1").await?;
599            notices.push(DepNotice {
600                repo: repo.clone(),
601                issue_number: *number,
602                comment: comment.clone(),
603                notify: false,
604                summary: comment.clone(),
605                task_id: *task_id,
606                title: title.clone(),
607            });
608        }
609    }
610    Ok(notices)
611}
612
613async fn failure_notices(store: &TaskStore) -> anyhow::Result<Vec<DepNotice>> {
614    let edges = store.list_dependency_edges().await?;
615    let mut status_cache: HashMap<(String, i64), Option<String>> = HashMap::new();
616    let mut snap_cache: HashMap<(String, i64), Option<BlockerSnapshot>> = HashMap::new();
617    let mut notices = Vec::new();
618    for edge in edges {
619        let status = blocker_status(
620            store,
621            &mut status_cache,
622            &edge.depends_on_repo,
623            edge.depends_on_number,
624        )
625        .await?;
626        let snap = cached_snapshot(
627            store,
628            &mut snap_cache,
629            &edge.depends_on_repo,
630            edge.depends_on_number,
631        )
632        .await?;
633        let Some(kind) = blocker_failure_kind(status.as_deref(), snap.as_ref()) else {
634            continue;
635        };
636        let key = format!(
637            "dep_blocker_notice:{}:{}#{}:{kind}",
638            edge.task_id, edge.depends_on_repo, edge.depends_on_number
639        );
640        if store.kv_get(&key).await?.is_some() {
641            continue;
642        }
643        store.kv_set(&key, "1").await?;
644        let label = dep_label(
645            &edge.task_repo,
646            &edge.depends_on_repo,
647            edge.depends_on_number,
648        );
649        let (comment, summary) = match kind {
650            "not_planned" => {
651                let text = format!(
652                    "Blocked by {label}, which was closed as not planned. This task stays waiting."
653                );
654                (format!("{text}{}", crate::engine::orch_footer()), text)
655            }
656            _ => {
657                let text = format!(
658                    "Blocked by {label}, which is blocked and needs a person. This task stays waiting."
659                );
660                (format!("{text}{}", crate::engine::orch_footer()), text)
661            }
662        };
663        let issue_number = edge
664            .task_external_id
665            .as_deref()
666            .and_then(|id| id.parse::<i64>().ok())
667            .filter(|number| *number > 0)
668            .unwrap_or(0);
669        notices.push(DepNotice {
670            repo: edge.task_repo,
671            issue_number,
672            comment,
673            notify: true,
674            summary,
675            task_id: edge.task_id,
676            title: edge.task_title,
677        });
678    }
679    Ok(notices)
680}
681
682async fn cached_snapshot(
683    store: &TaskStore,
684    cache: &mut HashMap<(String, i64), Option<BlockerSnapshot>>,
685    repo: &str,
686    number: i64,
687) -> anyhow::Result<Option<BlockerSnapshot>> {
688    if let Some(found) = cache.get(&(repo.to_string(), number)) {
689        return Ok(found.clone());
690    }
691    let snap = store.blocker_snapshot(repo, number).await?;
692    cache.insert((repo.to_string(), number), snap.clone());
693    Ok(snap)
694}
695
696async fn blocker_status(
697    store: &TaskStore,
698    cache: &mut HashMap<(String, i64), Option<String>>,
699    repo: &str,
700    number: i64,
701) -> anyhow::Result<Option<String>> {
702    if let Some(status) = cache.get(&(repo.to_string(), number)) {
703        return Ok(status.clone());
704    }
705    let status = store
706        .get_by_external_id(repo, &number.to_string())
707        .await?
708        .map(|task| task.status.as_str().to_string());
709    cache.insert((repo.to_string(), number), status.clone());
710    Ok(status)
711}
712
713fn find_cycles(adj: &HashMap<String, Vec<String>>) -> Vec<Vec<String>> {
714    let mut nodes: Vec<String> = adj.keys().cloned().collect();
715    for targets in adj.values() {
716        for target in targets {
717            if !nodes.iter().any(|node| node == target) {
718                nodes.push(target.clone());
719            }
720        }
721    }
722    nodes.sort();
723    let mut color: HashMap<String, u8> = HashMap::new();
724    let mut stack = Vec::new();
725    let mut cycles = Vec::new();
726    let mut seen = HashSet::new();
727    for node in nodes {
728        if color.get(&node).copied().unwrap_or(0) == 0 {
729            visit(&node, adj, &mut color, &mut stack, &mut cycles, &mut seen);
730        }
731    }
732    cycles
733}
734
735fn visit(
736    node: &str,
737    adj: &HashMap<String, Vec<String>>,
738    color: &mut HashMap<String, u8>,
739    stack: &mut Vec<String>,
740    cycles: &mut Vec<Vec<String>>,
741    seen: &mut HashSet<String>,
742) {
743    color.insert(node.to_string(), 1);
744    stack.push(node.to_string());
745    if let Some(nexts) = adj.get(node) {
746        for next in nexts {
747            match color.get(next).copied().unwrap_or(0) {
748                1 => {
749                    if let Some(start) = stack.iter().position(|item| item == next) {
750                        let mut cycle = stack[start..].to_vec();
751                        if let Some(min_at) = cycle
752                            .iter()
753                            .enumerate()
754                            .min_by(|left, right| left.1.cmp(right.1))
755                            .map(|(index, _)| index)
756                        {
757                            cycle.rotate_left(min_at);
758                        }
759                        let signature = {
760                            let mut sorted = cycle.clone();
761                            sorted.sort();
762                            sorted.join(",")
763                        };
764                        if seen.insert(signature) {
765                            cycles.push(cycle);
766                        }
767                    }
768                }
769                0 => visit(next, adj, color, stack, cycles, seen),
770                _ => {}
771            }
772        }
773    }
774    stack.pop();
775    color.insert(node.to_string(), 2);
776}
777
778#[cfg(test)]
779mod tests {
780    use super::*;
781    use crate::backends::{BlockedByIssue, ExternalTask, Status};
782    use crate::store::{NewTask, TaskStatus};
783    use async_trait::async_trait;
784    use std::sync::{Arc, Mutex};
785
786    #[test]
787    fn body_parser_reads_the_three_forms_and_skips_fences() {
788        let body = "\
789Depends on #12
790Blocked by #12, #13
791Depends on owner/repo#12
792```
793Depends on #99
794```
795See #4 in the notes
796";
797        let deps = parse_body_dependencies(body, "acme/app");
798        assert_eq!(
799            deps,
800            vec![
801                DepRef {
802                    repo: "acme/app".into(),
803                    number: 12
804                },
805                DepRef {
806                    repo: "acme/app".into(),
807                    number: 13
808                },
809                DepRef {
810                    repo: "owner/repo".into(),
811                    number: 12
812                },
813            ]
814        );
815    }
816
817    #[derive(Clone)]
818    struct ScriptedReviewComment {
819        login: String,
820        body: String,
821        created_at: String,
822    }
823
824    struct ScriptedBackend {
825        blocked: Mutex<HashMap<i64, BlockedByFetch>>,
826        comments: Mutex<Vec<(String, String)>>,
827        close_state: Mutex<HashMap<i64, (String, Option<String>)>>,
828        pulls: Mutex<HashMap<i64, Option<crate::backends::PullRequestCompletion>>>,
829        review_comments: Mutex<HashMap<i64, Vec<ScriptedReviewComment>>>,
830        reviewer: Mutex<Option<String>>,
831        /// Counts the blocker reads that cost a GitHub call.
832        reads: std::sync::atomic::AtomicUsize,
833    }
834
835    impl ScriptedBackend {
836        fn new(blocked: HashMap<i64, BlockedByFetch>) -> Self {
837            Self {
838                blocked: Mutex::new(blocked),
839                comments: Mutex::new(Vec::new()),
840                close_state: Mutex::new(HashMap::new()),
841                pulls: Mutex::new(HashMap::new()),
842                review_comments: Mutex::new(HashMap::new()),
843                reads: std::sync::atomic::AtomicUsize::new(0),
844                reviewer: Mutex::new(None),
845            }
846        }
847    }
848
849    #[async_trait]
850    impl ExternalBackend for ScriptedBackend {
851        fn name(&self) -> &str {
852            "scripted"
853        }
854
855        async fn create_task(
856            &self,
857            _title: &str,
858            _body: &str,
859            _labels: &[String],
860        ) -> anyhow::Result<ExternalId> {
861            Ok(ExternalId("1".into()))
862        }
863
864        async fn get_task(&self, id: &ExternalId) -> anyhow::Result<ExternalTask> {
865            Ok(ExternalTask {
866                id: id.clone(),
867                title: "t".into(),
868                body: String::new(),
869                state: "open".into(),
870                labels: vec![],
871                author: "bot".into(),
872                created_at: "2026-01-01T00:00:00Z".into(),
873                updated_at: "2026-01-01T00:00:00Z".into(),
874                url: String::new(),
875            })
876        }
877
878        async fn list_by_status(&self, _status: Status) -> anyhow::Result<Vec<ExternalTask>> {
879            Ok(vec![])
880        }
881
882        async fn post_comment(&self, id: &ExternalId, body: &str) -> anyhow::Result<()> {
883            self.comments
884                .lock()
885                .unwrap()
886                .push((id.0.clone(), body.to_string()));
887            Ok(())
888        }
889
890        async fn set_labels(&self, _id: &ExternalId, _labels: &[String]) -> anyhow::Result<()> {
891            Ok(())
892        }
893
894        async fn remove_label(&self, _id: &ExternalId, _label: &str) -> anyhow::Result<()> {
895            Ok(())
896        }
897
898        async fn get_sub_issues(&self, _id: &ExternalId) -> anyhow::Result<Vec<ExternalId>> {
899            Ok(vec![])
900        }
901
902        async fn health_check(&self) -> anyhow::Result<()> {
903            Ok(())
904        }
905
906        async fn blocked_by(
907            &self,
908            _repo: &str,
909            issue_number: i64,
910        ) -> anyhow::Result<BlockedByFetch> {
911            Ok(self
912                .blocked
913                .lock()
914                .unwrap()
915                .get(&issue_number)
916                .cloned()
917                .unwrap_or(BlockedByFetch::Unavailable))
918        }
919
920        async fn issue_close_state(
921            &self,
922            _repo: &str,
923            issue_number: i64,
924        ) -> anyhow::Result<Option<(String, Option<String>)>> {
925            self.reads
926                .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
927            Ok(self.close_state.lock().unwrap().get(&issue_number).cloned())
928        }
929
930        async fn pull_request_completion(
931            &self,
932            _repo: &str,
933            pr_number: i64,
934        ) -> anyhow::Result<Option<crate::backends::PullRequestCompletion>> {
935            self.reads
936                .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
937            let mut completion = self
938                .pulls
939                .lock()
940                .unwrap()
941                .get(&pr_number)
942                .cloned()
943                .unwrap_or(None);
944            if let Some(ref mut done) = completion {
945                if !done.approved {
946                    let comments = self
947                        .review_comments
948                        .lock()
949                        .unwrap()
950                        .get(&pr_number)
951                        .cloned()
952                        .unwrap_or_default();
953                    let reviewer = self.reviewer.lock().unwrap().clone().unwrap_or_default();
954                    let mapped: Vec<crate::backends::github::AutomatedReviewComment<'_>> = comments
955                        .iter()
956                        .map(|comment| crate::backends::github::AutomatedReviewComment {
957                            login: &comment.login,
958                            body: &comment.body,
959                            created_at: &comment.created_at,
960                        })
961                        .collect();
962                    done.approved = crate::backends::github::latest_automated_review_approves(
963                        &mapped, &reviewer,
964                    );
965                }
966            }
967            Ok(completion)
968        }
969    }
970
971    async fn insert(store: &TaskStore, repo: &str, number: &str, title: &str, body: &str) -> i64 {
972        store
973            .create(&NewTask {
974                external_id: Some(number.to_string()),
975                repo: repo.to_string(),
976                origin: "github".to_string(),
977                title: title.to_string(),
978                body: body.to_string(),
979                source: "manual".to_string(),
980                source_id: String::new(),
981                author: String::new(),
982                url: String::new(),
983                labels: vec![],
984                parent_id: None,
985            })
986            .await
987            .unwrap()
988    }
989
990    fn native(repo: &str, number: i64, state: &str, reason: Option<&str>) -> BlockedByIssue {
991        BlockedByIssue {
992            github_id: number,
993            repo: repo.to_string(),
994            number,
995            state: state.to_string(),
996            state_reason: reason.map(str::to_string),
997        }
998    }
999
1000    #[tokio::test]
1001    async fn native_list_replaces_rows_and_empty_native_clears_them() {
1002        let store = TaskStore::open_memory().await.unwrap();
1003        let b = insert(&store, "owner/repo", "2", "B", "Depends on #1").await;
1004        let mut blocked = HashMap::new();
1005        blocked.insert(
1006            2,
1007            BlockedByFetch::Native(vec![native("owner/repo", 1, "open", None)]),
1008        );
1009        let backend = ScriptedBackend::new(blocked);
1010        sync_issue_dependencies(&backend, &store, b, "owner/repo", Some(2), "Depends on #1")
1011            .await
1012            .unwrap();
1013        assert_eq!(store.list_dependencies(b).await.unwrap().len(), 1);
1014
1015        backend
1016            .blocked
1017            .lock()
1018            .unwrap()
1019            .insert(2, BlockedByFetch::Native(vec![]));
1020        sync_issue_dependencies(&backend, &store, b, "owner/repo", Some(2), "Depends on #1")
1021            .await
1022            .unwrap();
1023        assert!(store.list_dependencies(b).await.unwrap().is_empty());
1024        apply_claim_gates(&store).await.unwrap();
1025        assert_eq!(store.get(b).await.unwrap().status, TaskStatus::New);
1026    }
1027
1028    #[tokio::test]
1029    async fn unavailable_keeps_rows_when_the_body_has_no_deps_and_failed_keeps_rows() {
1030        let store = TaskStore::open_memory().await.unwrap();
1031        let b = insert(&store, "owner/repo", "2", "B", "").await;
1032        store
1033            .replace_dependencies(
1034                b,
1035                &[NewDependency {
1036                    repo: "owner/repo".into(),
1037                    number: 1,
1038                    source: DepSource::Cli,
1039                }],
1040            )
1041            .await
1042            .unwrap();
1043        let backend = ScriptedBackend::new(HashMap::new());
1044        sync_issue_dependencies(&backend, &store, b, "owner/repo", Some(2), "")
1045            .await
1046            .unwrap();
1047        assert_eq!(store.list_dependencies(b).await.unwrap()[0].source, "cli");
1048
1049        backend
1050            .blocked
1051            .lock()
1052            .unwrap()
1053            .insert(2, BlockedByFetch::Failed("boom".into()));
1054        sync_issue_dependencies(&backend, &store, b, "owner/repo", Some(2), "Depends on #9")
1055            .await
1056            .unwrap();
1057        assert_eq!(
1058            store.list_dependencies(b).await.unwrap()[0].depends_on_number,
1059            1
1060        );
1061    }
1062
1063    #[tokio::test]
1064    async fn unavailable_body_replaces_rows() {
1065        let store = TaskStore::open_memory().await.unwrap();
1066        let b = insert(
1067            &store,
1068            "acme/app",
1069            "8",
1070            "B",
1071            "Blocked by #12, #13\nDepends on owner/repo#12",
1072        )
1073        .await;
1074        let backend = ScriptedBackend::new(HashMap::new());
1075        sync_issue_dependencies(
1076            &backend,
1077            &store,
1078            b,
1079            "acme/app",
1080            Some(8),
1081            "Blocked by #12, #13\nDepends on owner/repo#12",
1082        )
1083        .await
1084        .unwrap();
1085        let deps = store.list_dependencies(b).await.unwrap();
1086        let labels: Vec<_> = deps
1087            .iter()
1088            .map(|dep| format!("{}#{}", dep.depends_on_repo, dep.depends_on_number))
1089            .collect();
1090        assert_eq!(
1091            labels,
1092            vec![
1093                "acme/app#12".to_string(),
1094                "acme/app#13".to_string(),
1095                "owner/repo#12".to_string()
1096            ]
1097        );
1098        assert!(deps.iter().all(|dep| dep.source == "body"));
1099    }
1100
1101    #[tokio::test]
1102    async fn cycle_comments_once_and_neither_task_is_claimable() {
1103        let store = TaskStore::open_memory().await.unwrap();
1104        let a = insert(&store, "owner/repo", "1", "A", "").await;
1105        let b = insert(&store, "owner/repo", "2", "B", "").await;
1106        let mut blocked = HashMap::new();
1107        blocked.insert(
1108            1,
1109            BlockedByFetch::Native(vec![native("owner/repo", 2, "open", None)]),
1110        );
1111        blocked.insert(
1112            2,
1113            BlockedByFetch::Native(vec![native("owner/repo", 1, "open", None)]),
1114        );
1115        let backend = ScriptedBackend::new(blocked);
1116        sync_issue_dependencies(&backend, &store, a, "owner/repo", Some(1), "")
1117            .await
1118            .unwrap();
1119        sync_issue_dependencies(&backend, &store, b, "owner/repo", Some(2), "")
1120            .await
1121            .unwrap();
1122        apply_claim_gates(&store).await.unwrap();
1123        assert_eq!(store.get(a).await.unwrap().status, TaskStatus::Waiting);
1124        assert_eq!(store.get(b).await.unwrap().status, TaskStatus::Waiting);
1125        let first = emit_dependency_notices(&backend, &store, None)
1126            .await
1127            .unwrap();
1128        assert_eq!(first.len(), 2);
1129        assert!(first
1130            .iter()
1131            .all(|notice| notice.comment.contains("owner/repo#1")));
1132        assert!(first
1133            .iter()
1134            .all(|notice| notice.comment.contains("owner/repo#2")));
1135        let second = emit_dependency_notices(&backend, &store, None)
1136            .await
1137            .unwrap();
1138        assert!(second.is_empty());
1139        assert_eq!(backend.comments.lock().unwrap().len(), 2);
1140    }
1141
1142    #[tokio::test]
1143    async fn not_planned_keeps_waiting_and_notifies_once() {
1144        let store = TaskStore::open_memory().await.unwrap();
1145        let b = insert(&store, "owner/repo", "2", "B", "").await;
1146        let mut blocked = HashMap::new();
1147        blocked.insert(
1148            2,
1149            BlockedByFetch::Native(vec![native("owner/repo", 1, "closed", Some("not_planned"))]),
1150        );
1151        let backend = ScriptedBackend::new(blocked);
1152        let transport = Transport::new();
1153        let mut rx = transport.subscribe_notifications();
1154        sync_issue_dependencies(&backend, &store, b, "owner/repo", Some(2), "")
1155            .await
1156            .unwrap();
1157        apply_claim_gates(&store).await.unwrap();
1158        assert_eq!(store.get(b).await.unwrap().status, TaskStatus::Waiting);
1159        let notices = emit_dependency_notices(&backend, &store, Some(&transport))
1160            .await
1161            .unwrap();
1162        assert_eq!(notices.len(), 1);
1163        assert!(notices[0].notify);
1164        assert!(notices[0].summary.contains("not planned"));
1165        let again = emit_dependency_notices(&backend, &store, Some(&transport))
1166            .await
1167            .unwrap();
1168        assert!(again.is_empty());
1169        let note = rx.try_recv().unwrap();
1170        assert_eq!(note.status, "failed");
1171        assert!(rx.try_recv().is_err());
1172        assert_eq!(backend.comments.lock().unwrap().len(), 1);
1173    }
1174
1175    #[tokio::test]
1176    async fn waiting_task_is_not_routed_until_the_blocker_is_done() {
1177        use crate::engine::router::{Router, RouterConfig};
1178        use crate::engine::tasks::TaskManager;
1179        use crate::engine::tick::{tick_route_tasks, RoutingTickQuota};
1180
1181        crate::engine::cooldown::reset_global_state().await;
1182        let store = Arc::new(TaskStore::open_memory().await.unwrap());
1183        let a = insert(&store, "owner/repo", "1", "A", "").await;
1184        let b = insert(&store, "owner/repo", "2", "B", "").await;
1185        store
1186            .replace_dependencies(
1187                b,
1188                &[NewDependency {
1189                    repo: "owner/repo".into(),
1190                    number: 1,
1191                    source: DepSource::Github,
1192                }],
1193            )
1194            .await
1195            .unwrap();
1196
1197        let backend: Arc<dyn ExternalBackend> =
1198            Arc::new(crate::backends::test_helpers::NoopBackend);
1199        let task_manager = Arc::new(TaskManager::with_store(
1200            backend.clone(),
1201            store.clone(),
1202            "owner/repo".into(),
1203        ));
1204        let mut tier = HashMap::new();
1205        tier.insert("claude".to_string(), vec!["haiku".to_string()]);
1206        let mut model_map = HashMap::new();
1207        model_map.insert("simple".into(), tier.clone());
1208        model_map.insert("medium".into(), tier.clone());
1209        model_map.insert("complex".into(), tier);
1210        let round_robin = RouterConfig {
1211            mode: "round_robin".into(),
1212            agents: vec!["claude".into()],
1213            model_map: model_map.clone(),
1214            ..Default::default()
1215        };
1216        let mut router = Router::new_for_test(round_robin, vec!["claude".into()]);
1217        let mut quota = RoutingTickQuota::new(10);
1218        tick_route_tasks(
1219            &backend,
1220            &task_manager,
1221            &mut router,
1222            &store,
1223            "owner/repo",
1224            &mut quota,
1225        )
1226        .await
1227        .unwrap();
1228        let waiting = store.get(b).await.unwrap();
1229        assert_eq!(waiting.status, TaskStatus::Waiting);
1230        assert!(waiting.agent.is_none());
1231
1232        let llm = RouterConfig {
1233            mode: "llm".into(),
1234            max_route_attempts: 0,
1235            agents: vec!["claude".into()],
1236            model_map,
1237            ..Default::default()
1238        };
1239        let mut llm_router = Router::new_for_test(llm, vec!["claude".into()]);
1240        let ext = crate::engine::tasks::store_task_to_external(&store.get(b).await.unwrap());
1241        store.update_status(b, TaskStatus::New).await.unwrap();
1242        let err = llm_router
1243            .route(&ext, &store, "owner/repo")
1244            .await
1245            .unwrap_err();
1246        assert!(err
1247            .downcast_ref::<crate::store::WaitingOnDependency>()
1248            .is_some());
1249        assert_eq!(store.get(b).await.unwrap().status, TaskStatus::Waiting);
1250        assert_eq!(store.get(b).await.unwrap().route_attempts, 0);
1251
1252        store.update_status(a, TaskStatus::Done).await.unwrap();
1253        let mut quota = RoutingTickQuota::new(10);
1254        tick_route_tasks(
1255            &backend,
1256            &task_manager,
1257            &mut router,
1258            &store,
1259            "owner/repo",
1260            &mut quota,
1261        )
1262        .await
1263        .unwrap();
1264        assert_eq!(store.get(b).await.unwrap().status, TaskStatus::Waiting);
1265
1266        store
1267            .save_blocker_snapshot(
1268                "owner/repo",
1269                1,
1270                &BlockerSnapshot::observe(
1271                    "closed",
1272                    Some("completed"),
1273                    Some(&ObservedPullRequest {
1274                        merged: true,
1275                        base_is_default: true,
1276                        approved: true,
1277                        closed_unmerged: false,
1278                    }),
1279                ),
1280            )
1281            .await
1282            .unwrap();
1283        let mut quota = RoutingTickQuota::new(10);
1284        tick_route_tasks(
1285            &backend,
1286            &task_manager,
1287            &mut router,
1288            &store,
1289            "owner/repo",
1290            &mut quota,
1291        )
1292        .await
1293        .unwrap();
1294        let routed = store.get(b).await.unwrap();
1295        assert_eq!(routed.status, TaskStatus::Routed);
1296        assert!(routed.agent.is_some());
1297    }
1298
1299    #[tokio::test]
1300    async fn round_robin_route_returns_waiting_on_dependency() {
1301        use crate::engine::router::{Router, RouterConfig};
1302        use crate::engine::tasks::store_task_to_external;
1303
1304        crate::engine::cooldown::reset_global_state().await;
1305        let store = Arc::new(TaskStore::open_memory().await.unwrap());
1306        let b = insert(&store, "owner/repo", "2", "B", "").await;
1307        store
1308            .replace_dependencies(
1309                b,
1310                &[NewDependency {
1311                    repo: "owner/repo".into(),
1312                    number: 1,
1313                    source: DepSource::Github,
1314                }],
1315            )
1316            .await
1317            .unwrap();
1318        let ext = store_task_to_external(&store.get(b).await.unwrap());
1319        let mut tier = HashMap::new();
1320        tier.insert("claude".to_string(), vec!["haiku".to_string()]);
1321        let mut model_map = HashMap::new();
1322        model_map.insert("medium".into(), tier);
1323        let config = RouterConfig {
1324            mode: "round_robin".into(),
1325            agents: vec!["claude".into()],
1326            model_map,
1327            ..Default::default()
1328        };
1329        let mut router = Router::new_for_test(config, vec!["claude".into()]);
1330        let err = router.route(&ext, &store, "owner/repo").await.unwrap_err();
1331        assert!(err
1332            .downcast_ref::<crate::store::WaitingOnDependency>()
1333            .is_some());
1334        assert_eq!(store.get(b).await.unwrap().status, TaskStatus::Waiting);
1335        assert_eq!(store.get(b).await.unwrap().route_attempts, 0);
1336    }
1337
1338    #[tokio::test]
1339    async fn a_blocker_already_known_done_costs_no_github_call() {
1340        let store = TaskStore::open_memory().await.unwrap();
1341        insert(&store, "owner/repo", "1", "A", "").await;
1342        let b = insert(&store, "owner/repo", "2", "B", "").await;
1343        store
1344            .replace_dependencies(
1345                b,
1346                &[NewDependency {
1347                    repo: "owner/repo".into(),
1348                    number: 1,
1349                    source: DepSource::Github,
1350                }],
1351            )
1352            .await
1353            .unwrap();
1354        let backend = ScriptedBackend::new(HashMap::new());
1355        backend
1356            .close_state
1357            .lock()
1358            .unwrap()
1359            .insert(1, ("closed".into(), Some("completed".into())));
1360
1361        refresh_blocker_snapshots(&backend, &store).await.unwrap();
1362        let first = backend.reads.load(std::sync::atomic::Ordering::Relaxed);
1363        assert!(first > 0, "the first refresh has to read the blocker");
1364        assert!(store.block_claim_if_waiting(b).await.unwrap().is_none());
1365
1366        refresh_blocker_snapshots(&backend, &store).await.unwrap();
1367        refresh_blocker_snapshots(&backend, &store).await.unwrap();
1368        assert_eq!(
1369            backend.reads.load(std::sync::atomic::Ordering::Relaxed),
1370            first,
1371            "a blocker closed as completed must not be read again"
1372        );
1373    }
1374
1375    #[tokio::test]
1376    async fn refresh_holds_until_the_pr_is_approved_and_merged_into_the_default_branch() {
1377        use crate::backends::PullRequestCompletion;
1378
1379        let store = TaskStore::open_memory().await.unwrap();
1380        let a = insert(&store, "owner/repo", "1", "A", "").await;
1381        let b = insert(&store, "owner/repo", "2", "B", "").await;
1382        store
1383            .set_fields(a, &[("pr_number", serde_json::json!(10))])
1384            .await
1385            .unwrap();
1386        store
1387            .replace_dependencies(
1388                b,
1389                &[NewDependency {
1390                    repo: "owner/repo".into(),
1391                    number: 1,
1392                    source: DepSource::Github,
1393                }],
1394            )
1395            .await
1396            .unwrap();
1397        let backend = ScriptedBackend::new(HashMap::new());
1398        backend
1399            .close_state
1400            .lock()
1401            .unwrap()
1402            .insert(1, ("closed".into(), Some("completed".into())));
1403
1404        let open_pr = PullRequestCompletion {
1405            merged: false,
1406            base_ref: "main".into(),
1407            default_branch: "main".into(),
1408            approved: false,
1409            closed: false,
1410        };
1411        backend.pulls.lock().unwrap().insert(10, Some(open_pr));
1412        refresh_blocker_snapshots(&backend, &store).await.unwrap();
1413        assert!(store.block_claim_if_waiting(b).await.unwrap().is_some());
1414
1415        backend.pulls.lock().unwrap().insert(
1416            10,
1417            Some(PullRequestCompletion {
1418                merged: false,
1419                base_ref: "main".into(),
1420                default_branch: "main".into(),
1421                approved: true,
1422                closed: false,
1423            }),
1424        );
1425        refresh_blocker_snapshots(&backend, &store).await.unwrap();
1426        assert!(store.block_claim_if_waiting(b).await.unwrap().is_some());
1427
1428        backend.pulls.lock().unwrap().insert(
1429            10,
1430            Some(PullRequestCompletion {
1431                merged: true,
1432                base_ref: "feature".into(),
1433                default_branch: "main".into(),
1434                approved: true,
1435                closed: true,
1436            }),
1437        );
1438        refresh_blocker_snapshots(&backend, &store).await.unwrap();
1439        assert!(store.block_claim_if_waiting(b).await.unwrap().is_some());
1440
1441        backend.pulls.lock().unwrap().insert(
1442            10,
1443            Some(PullRequestCompletion {
1444                merged: false,
1445                base_ref: "main".into(),
1446                default_branch: "main".into(),
1447                approved: true,
1448                closed: true,
1449            }),
1450        );
1451        refresh_blocker_snapshots(&backend, &store).await.unwrap();
1452        assert!(store.block_claim_if_waiting(b).await.unwrap().is_some());
1453
1454        backend.pulls.lock().unwrap().insert(
1455            10,
1456            Some(PullRequestCompletion {
1457                merged: true,
1458                base_ref: "main".into(),
1459                default_branch: "main".into(),
1460                approved: true,
1461                closed: true,
1462            }),
1463        );
1464        refresh_blocker_snapshots(&backend, &store).await.unwrap();
1465        assert_eq!(store.block_claim_if_waiting(b).await.unwrap(), None);
1466        assert_eq!(store.get(b).await.unwrap().status, TaskStatus::New);
1467    }
1468
1469    #[tokio::test]
1470    async fn comment_approval_from_reviewer_releases_a_merged_blocker() {
1471        use crate::backends::PullRequestCompletion;
1472
1473        let store = TaskStore::open_memory().await.unwrap();
1474        let a = insert(&store, "owner/repo", "1", "A", "").await;
1475        let b = insert(&store, "owner/repo", "2", "B", "").await;
1476        store
1477            .set_fields(a, &[("pr_number", serde_json::json!(10))])
1478            .await
1479            .unwrap();
1480        store
1481            .replace_dependencies(
1482                b,
1483                &[NewDependency {
1484                    repo: "owner/repo".into(),
1485                    number: 1,
1486                    source: DepSource::Github,
1487                }],
1488            )
1489            .await
1490            .unwrap();
1491        let backend = ScriptedBackend::new(HashMap::new());
1492        backend
1493            .close_state
1494            .lock()
1495            .unwrap()
1496            .insert(1, ("closed".into(), Some("completed".into())));
1497        backend.pulls.lock().unwrap().insert(
1498            10,
1499            Some(PullRequestCompletion {
1500                merged: true,
1501                base_ref: "main".into(),
1502                default_branch: "main".into(),
1503                approved: false,
1504                closed: true,
1505            }),
1506        );
1507        *backend.reviewer.lock().unwrap() = Some("orch-bot".into());
1508        backend.review_comments.lock().unwrap().insert(
1509            10,
1510            vec![ScriptedReviewComment {
1511                login: "mallory".into(),
1512                body: "## Automated Review \u{2014} Approve\n\nspoof".into(),
1513                created_at: "2026-01-03T00:00:00Z".into(),
1514            }],
1515        );
1516        refresh_blocker_snapshots(&backend, &store).await.unwrap();
1517        assert!(store.block_claim_if_waiting(b).await.unwrap().is_some());
1518
1519        backend.review_comments.lock().unwrap().insert(
1520            10,
1521            vec![ScriptedReviewComment {
1522                login: "orch-bot".into(),
1523                body: "## Automated Review \u{2014} Approve\n\nLooks good.".into(),
1524                created_at: "2026-01-02T00:00:00Z".into(),
1525            }],
1526        );
1527        refresh_blocker_snapshots(&backend, &store).await.unwrap();
1528        assert_eq!(store.block_claim_if_waiting(b).await.unwrap(), None);
1529        assert_eq!(store.get(b).await.unwrap().status, TaskStatus::New);
1530    }
1531
1532    #[tokio::test]
1533    async fn blocker_closed_before_import_releases_on_refresh() {
1534        let store = TaskStore::open_memory().await.unwrap();
1535        let waiter = insert(&store, "owner/repo", "24", "later", "").await;
1536        let mut blocked = HashMap::new();
1537        blocked.insert(
1538            24,
1539            BlockedByFetch::Native(vec![native("owner/repo", 14, "open", None)]),
1540        );
1541        let backend = ScriptedBackend::new(blocked);
1542        backend
1543            .close_state
1544            .lock()
1545            .unwrap()
1546            .insert(14, ("closed".into(), Some("completed".into())));
1547        reconcile_dependencies(&backend, &store, None)
1548            .await
1549            .unwrap();
1550        assert_eq!(store.get(waiter).await.unwrap().status, TaskStatus::New);
1551        let snap = store
1552            .blocker_snapshot("owner/repo", 14)
1553            .await
1554            .unwrap()
1555            .unwrap();
1556        assert!(snap.is_satisfied());
1557        assert_eq!(snap.issue_state, "closed");
1558    }
1559
1560    #[test]
1561    fn depends_on_token_accepts_a_bare_number_and_a_cross_repo_ref() {
1562        assert_eq!(
1563            parse_depends_on_token("12", "acme/app"),
1564            Some(DepRef {
1565                repo: "acme/app".into(),
1566                number: 12
1567            })
1568        );
1569        assert_eq!(
1570            parse_depends_on_token("owner/repo#12", "acme/app"),
1571            Some(DepRef {
1572                repo: "owner/repo".into(),
1573                number: 12
1574            })
1575        );
1576    }
1577
1578    #[tokio::test]
1579    async fn two_route_calls_cannot_claim_a_waiting_task() {
1580        use crate::engine::router::{Router, RouterConfig};
1581        use crate::engine::tasks::store_task_to_external;
1582
1583        crate::engine::cooldown::reset_global_state().await;
1584        let store = Arc::new(TaskStore::open_memory().await.unwrap());
1585        let b = insert(&store, "owner/repo", "2", "B", "").await;
1586        store
1587            .replace_dependencies(
1588                b,
1589                &[NewDependency {
1590                    repo: "owner/repo".into(),
1591                    number: 1,
1592                    source: DepSource::Github,
1593                }],
1594            )
1595            .await
1596            .unwrap();
1597        let ext = store_task_to_external(&store.get(b).await.unwrap());
1598        let mut tier = HashMap::new();
1599        tier.insert("claude".to_string(), vec!["haiku".to_string()]);
1600        let mut model_map = HashMap::new();
1601        model_map.insert("medium".into(), tier);
1602        let config = RouterConfig {
1603            mode: "round_robin".into(),
1604            agents: vec!["claude".into()],
1605            model_map,
1606            ..Default::default()
1607        };
1608        let left_store = store.clone();
1609        let right_store = store.clone();
1610        let left_task = ext.clone();
1611        let right_task = ext;
1612        let right_config = config.clone();
1613        let left = tokio::spawn(async move {
1614            let mut router = Router::new_for_test(config, vec!["claude".into()]);
1615            router.route(&left_task, &left_store, "owner/repo").await
1616        });
1617        let right = tokio::spawn(async move {
1618            let mut router = Router::new_for_test(right_config, vec!["claude".into()]);
1619            router.route(&right_task, &right_store, "owner/repo").await
1620        });
1621        assert!(left.await.unwrap().is_err());
1622        assert!(right.await.unwrap().is_err());
1623        let task = store.get(b).await.unwrap();
1624        assert_eq!(task.status, TaskStatus::Waiting);
1625        assert!(task.agent.is_none());
1626    }
1627
1628    #[tokio::test]
1629    async fn refuse_if_waiting_blocks_manual_claim() {
1630        let store = TaskStore::open_memory().await.unwrap();
1631        let b = insert(&store, "owner/repo", "2", "B", "").await;
1632        store
1633            .replace_dependencies(
1634                b,
1635                &[NewDependency {
1636                    repo: "owner/repo".into(),
1637                    number: 1,
1638                    source: DepSource::Cli,
1639                }],
1640            )
1641            .await
1642            .unwrap();
1643        let err = refuse_if_waiting(&store, b).await.unwrap_err();
1644        assert!(err.to_string().contains("#1"));
1645        assert_eq!(store.get(b).await.unwrap().status, TaskStatus::Waiting);
1646    }
1647}