Skip to main content

orch/store/
dependencies.rs

1//! Issue dependency rows and the claim gate that keeps a waiting task from starting.
2//!
3//! A dependency is done when its blocker snapshot says the blocking pull request
4//! was merged into the default branch, or the issue has no pull request and was
5//! closed as completed. A local `done` row is not enough.
6//! `not_planned` and `duplicate` are not done. `new`, `routed`, `waiting`,
7//! `blocked`, and `needs_review` move to `waiting` while a dependency is open.
8//! `in_progress` and `in_review` stay where they are.
9//! A `blocked-external` note in the issue body is an unmet blocker until a later
10//! import removes it
11
12/// The SQL behind each replicated dependency write
13pub(crate) mod apply;
14
15use super::{TaskStatus, TaskStore};
16use crate::cluster::Command;
17use crate::cluster::Reply;
18use anyhow::Context;
19use sqlx::Row;
20use sqlx::SqliteConnection;
21use std::collections::HashMap;
22
23/// Returned by routing and dispatch when a task still has an open dependency.
24///
25/// Callers log this and leave the task alone. It does not count as a routing failure
26#[derive(Debug, thiserror::Error)]
27#[error("waiting on {}", .blockers.join(", "))]
28pub struct WaitingOnDependency {
29    pub blockers: Vec<String>,
30}
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
33pub enum DepSource {
34    Github,
35    Body,
36    Cli,
37}
38
39impl DepSource {
40    pub fn as_str(self) -> &'static str {
41        match self {
42            Self::Github => "github",
43            Self::Body => "body",
44            Self::Cli => "cli",
45        }
46    }
47
48    pub fn parse(value: &str) -> Option<Self> {
49        match value {
50            "github" => Some(Self::Github),
51            "body" => Some(Self::Body),
52            "cli" => Some(Self::Cli),
53            _ => None,
54        }
55    }
56}
57
58#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
59pub struct NewDependency {
60    pub repo: String,
61    pub number: i64,
62    pub source: DepSource,
63}
64
65#[derive(Debug, Clone)]
66pub struct TaskDependency {
67    pub task_id: i64,
68    pub depends_on_task_id: Option<i64>,
69    pub depends_on_repo: String,
70    pub depends_on_number: i64,
71    pub source: String,
72    pub blocker_status: Option<String>,
73}
74
75#[derive(Debug, Clone)]
76pub struct SchedulableTask {
77    pub id: i64,
78    pub repo: String,
79    pub status: String,
80    pub external_id: Option<String>,
81    pub body: String,
82    pub title: String,
83}
84
85#[derive(Debug, Clone)]
86pub struct DepEdge {
87    pub task_id: i64,
88    pub task_repo: String,
89    pub task_external_id: Option<String>,
90    pub task_title: String,
91    pub depends_on_repo: String,
92    pub depends_on_number: i64,
93}
94
95#[derive(Debug, Clone)]
96struct BlockerView {
97    label: String,
98    satisfied: bool,
99}
100
101/// Pull request fields the engine observed on GitHub.
102///
103/// The store does not talk to GitHub. The engine fills this and the snapshot
104/// decides whether the blocker is done
105#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
106#[expect(
107    clippy::struct_excessive_bools,
108    reason = "each bool mirrors a github field and the struct is stored as json"
109)]
110pub struct ObservedPullRequest {
111    pub merged: bool,
112    pub base_is_default: bool,
113    pub approved: bool,
114    pub closed_unmerged: bool,
115}
116
117/// What orch last read about one blocking issue.
118///
119/// Missing JSON fails closed: the dependency stays open
120#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
121#[expect(
122    clippy::struct_excessive_bools,
123    reason = "each bool mirrors a github field and the struct is stored as json"
124)]
125pub struct BlockerSnapshot {
126    pub issue_state: String,
127    pub state_reason: Option<String>,
128    pub has_pr: bool,
129    pub pr_merged: bool,
130    pub pr_merged_into_default: bool,
131    pub pr_approved: bool,
132    pub pr_closed: bool,
133}
134
135impl BlockerSnapshot {
136    pub fn observe(
137        issue_state: &str,
138        state_reason: Option<&str>,
139        pr: Option<&ObservedPullRequest>,
140    ) -> Self {
141        let normalized = normalize_close_reason(issue_state, state_reason);
142        let (has_pr, merged, into_default, approved, pr_closed) = match pr {
143            Some(pr) => (
144                true,
145                pr.merged,
146                pr.base_is_default,
147                pr.approved,
148                pr.closed_unmerged,
149            ),
150            None => (false, false, false, false, false),
151        };
152        Self {
153            issue_state: if issue_state.eq_ignore_ascii_case("closed") {
154                "closed".to_string()
155            } else {
156                "open".to_string()
157            },
158            state_reason: Some(normalized.to_string()),
159            has_pr,
160            pr_merged: merged,
161            pr_merged_into_default: into_default,
162            pr_approved: approved,
163            pr_closed,
164        }
165    }
166
167    /// Record a new issue state without clearing pull request fields.
168    ///
169    /// A later issue sync must not wipe a snapshot that already knows the PR
170    pub fn note_issue_state(&mut self, issue_state: &str, state_reason: Option<&str>) {
171        let normalized = normalize_close_reason(issue_state, state_reason);
172        self.issue_state = if issue_state.eq_ignore_ascii_case("closed") {
173            "closed".to_string()
174        } else {
175            "open".to_string()
176        };
177        self.state_reason = Some(normalized.to_string());
178    }
179
180    pub fn is_satisfied(&self) -> bool {
181        let reason = self.state_reason.as_deref().unwrap_or("");
182        if reason == "not_planned" || reason == "duplicate" {
183            return false;
184        }
185        if self.has_pr {
186            // A merge into the default branch is the decision, even without an approving review
187            return self.pr_merged && self.pr_merged_into_default;
188        }
189        self.issue_state == "closed" && (reason.is_empty() || reason == "completed")
190    }
191
192    /// Why the blocker is not done, for the CLI. `None` when it is satisfied
193    pub fn unsatisfied_reason(&self) -> Option<String> {
194        if self.is_satisfied() {
195            return None;
196        }
197        let reason = self.state_reason.as_deref().unwrap_or("");
198        if self.issue_state != "closed" {
199            return Some(if self.has_pr {
200                "open, PR not merged".to_string()
201            } else {
202                "open".to_string()
203            });
204        }
205        if reason == "not_planned" || reason == "duplicate" {
206            return Some(format!("closed as {reason}"));
207        }
208        if self.pr_closed {
209            return Some("closed, PR closed unmerged".to_string());
210        }
211        if self.has_pr {
212            return Some(if self.pr_merged {
213                "closed, PR merged outside the default branch".to_string()
214            } else {
215                "closed, PR not merged".to_string()
216            });
217        }
218        Some("closed".to_string())
219    }
220}
221
222/// Map a GitHub issue `state` and `state_reason` onto the values stored in KV.
223///
224/// A classic close with no reason counts as completed. An open issue counts as open
225pub(crate) fn normalize_close_reason(state: &str, state_reason: Option<&str>) -> &'static str {
226    if !state.eq_ignore_ascii_case("closed") {
227        return "open";
228    }
229    match state_reason
230        .unwrap_or("")
231        .trim()
232        .to_ascii_lowercase()
233        .as_str()
234    {
235        "not_planned" => "not_planned",
236        "duplicate" => "duplicate",
237        "reopened" => "open",
238        _ => "completed",
239    }
240}
241
242pub fn blocker_snapshot_key(repo: &str, number: i64) -> String {
243    format!("dep_blocker:{repo}#{number}")
244}
245
246pub fn worktree_refresh_key(task_id: i64) -> String {
247    format!("dep_worktree_refresh:{task_id}")
248}
249
250pub fn dep_label(task_repo: &str, dep_repo: &str, number: i64) -> String {
251    if task_repo == dep_repo {
252        format!("#{number}")
253    } else {
254        format!("{dep_repo}#{number}")
255    }
256}
257
258/// A missing snapshot is not done. Local task status is not an input
259pub fn blocker_is_satisfied(snapshot: Option<&BlockerSnapshot>) -> bool {
260    snapshot.is_some_and(BlockerSnapshot::is_satisfied)
261}
262
263/// `not_planned` and a still-open `blocked` task each get one notice.
264///
265/// `duplicate` does not. No snapshot means no notice
266pub fn blocker_failure_kind(
267    task_status: Option<&str>,
268    snapshot: Option<&BlockerSnapshot>,
269) -> Option<&'static str> {
270    let snap = snapshot?;
271    if snap.state_reason.as_deref() == Some("not_planned") {
272        return Some("not_planned");
273    }
274    if task_status == Some("blocked") && !snap.is_satisfied() {
275        return Some("blocked");
276    }
277    None
278}
279
280impl TaskStore {
281    /// Rename a repo slug on every stored row in one transaction.
282    ///
283    /// Refuses when the new slug already has tasks. Returns the number of moved tasks
284    pub async fn move_repo(&self, old: &str, new: &str) -> anyhow::Result<u64> {
285        let cmd = Command::MoveRepo {
286            old: old.to_string(),
287            new: new.to_string(),
288        };
289        Ok(u64::try_from(self.write_int(cmd).await?)?)
290    }
291
292    /// Replace one task's dependency rows in a single transaction.
293    pub async fn replace_dependencies(
294        &self,
295        task_id: i64,
296        rows: &[NewDependency],
297    ) -> anyhow::Result<()> {
298        let cmd = Command::DepReplace {
299            task_id,
300            rows: rows.to_vec(),
301        };
302        self.write(cmd).await.map(|_| ())
303    }
304
305    /// Point dependency rows at a task orch now knows.
306    pub async fn link_known_task(
307        &self,
308        task_id: i64,
309        repo: &str,
310        number: i64,
311    ) -> anyhow::Result<()> {
312        let cmd = Command::DepLinkKnown {
313            task_id,
314            repo: repo.to_string(),
315            number,
316        };
317        self.write(cmd).await.map(|_| ())
318    }
319
320    pub async fn list_dependencies(&self, task_id: i64) -> anyhow::Result<Vec<TaskDependency>> {
321        let rows = sqlx::query(
322            "SELECT d.task_id, d.depends_on_task_id, d.depends_on_repo, d.depends_on_number, d.source,
323                    t.status AS blocker_status
324             FROM task_dependencies d
325             LEFT JOIN tasks t
326               ON t.repo = d.depends_on_repo
327              AND t.external_id = CAST(d.depends_on_number AS TEXT)
328             WHERE d.task_id = ?
329             ORDER BY d.depends_on_repo, d.depends_on_number",
330        )
331        .bind(task_id)
332        .fetch_all(&self.pool)
333        .await?;
334        let mut out = Vec::with_capacity(rows.len());
335        for row in rows {
336            out.push(TaskDependency {
337                task_id: row.try_get("task_id")?,
338                depends_on_task_id: row.try_get("depends_on_task_id")?,
339                depends_on_repo: row.try_get("depends_on_repo")?,
340                depends_on_number: row.try_get("depends_on_number")?,
341                source: row.try_get("source")?,
342                blocker_status: row.try_get("blocker_status")?,
343            });
344        }
345        Ok(out)
346    }
347
348    pub async fn list_dependents(
349        &self,
350        repo: &str,
351        number: i64,
352    ) -> anyhow::Result<Vec<TaskDependency>> {
353        let rows = sqlx::query(
354            "SELECT d.task_id, d.depends_on_task_id, d.depends_on_repo, d.depends_on_number, d.source,
355                    t.status AS blocker_status
356             FROM task_dependencies d
357             JOIN tasks t ON t.id = d.task_id
358             WHERE d.depends_on_repo = ? AND d.depends_on_number = ?
359             ORDER BY d.task_id",
360        )
361        .bind(repo)
362        .bind(number)
363        .fetch_all(&self.pool)
364        .await?;
365        let mut out = Vec::with_capacity(rows.len());
366        for row in rows {
367            out.push(TaskDependency {
368                task_id: row.try_get("task_id")?,
369                depends_on_task_id: row.try_get("depends_on_task_id")?,
370                depends_on_repo: row.try_get("depends_on_repo")?,
371                depends_on_number: row.try_get("depends_on_number")?,
372                source: row.try_get("source")?,
373                blocker_status: row.try_get("blocker_status")?,
374            });
375        }
376        Ok(out)
377    }
378
379    pub async fn list_schedulable(&self) -> anyhow::Result<Vec<SchedulableTask>> {
380        let rows = sqlx::query(
381            "SELECT id, repo, status, external_id, body, title
382             FROM tasks
383             WHERE status IN ('new', 'routed', 'waiting')
384             ORDER BY id",
385        )
386        .fetch_all(&self.pool)
387        .await?;
388        let mut out = Vec::with_capacity(rows.len());
389        for row in rows {
390            out.push(SchedulableTask {
391                id: row.try_get("id")?,
392                repo: row.try_get("repo")?,
393                status: row.try_get("status")?,
394                external_id: row.try_get("external_id")?,
395                body: row.try_get("body")?,
396                title: row.try_get("title")?,
397            });
398        }
399        Ok(out)
400    }
401
402    pub async fn list_dependency_edges(&self) -> anyhow::Result<Vec<DepEdge>> {
403        let rows = sqlx::query(
404            "SELECT d.task_id, t.repo AS task_repo, t.external_id AS task_external_id, t.title AS task_title,
405                    d.depends_on_repo, d.depends_on_number
406             FROM task_dependencies d
407             JOIN tasks t ON t.id = d.task_id",
408        )
409        .fetch_all(&self.pool)
410        .await?;
411        let mut out = Vec::with_capacity(rows.len());
412        for row in rows {
413            out.push(DepEdge {
414                task_id: row.try_get("task_id")?,
415                task_repo: row.try_get("task_repo")?,
416                task_external_id: row.try_get("task_external_id")?,
417                task_title: row.try_get("task_title")?,
418                depends_on_repo: row.try_get("depends_on_repo")?,
419                depends_on_number: row.try_get("depends_on_number")?,
420            });
421        }
422        Ok(out)
423    }
424
425    /// Labels of unmet blockers, keyed by the waiting task id.
426    pub async fn unmet_labels_by_task(&self) -> anyhow::Result<HashMap<i64, Vec<String>>> {
427        let mut conn = self.pool.acquire().await?;
428        let ids: Vec<i64> = sqlx::query("SELECT DISTINCT task_id FROM task_dependencies")
429            .fetch_all(&mut *conn)
430            .await?
431            .into_iter()
432            .map(|row| row.try_get("task_id"))
433            .collect::<Result<Vec<_>, _>>()?;
434        let mut out = HashMap::new();
435        for task_id in ids {
436            let repo: String = sqlx::query("SELECT repo FROM tasks WHERE id = ?")
437                .bind(task_id)
438                .fetch_optional(&mut *conn)
439                .await?
440                .and_then(|row| row.try_get("repo").ok())
441                .unwrap_or_default();
442            let views = load_blockers(&mut conn, task_id, &repo).await?;
443            let labels: Vec<String> = views
444                .into_iter()
445                .filter(|view| !view.satisfied)
446                .map(|view| view.label)
447                .collect();
448            if !labels.is_empty() {
449                out.insert(task_id, labels);
450            }
451        }
452        Ok(out)
453    }
454
455    pub async fn unmet_blocker_labels(&self, task_id: i64) -> anyhow::Result<Vec<String>> {
456        let mut conn = self.pool.acquire().await?;
457        let repo: String = sqlx::query("SELECT repo FROM tasks WHERE id = ?")
458            .bind(task_id)
459            .fetch_optional(&mut *conn)
460            .await?
461            .and_then(|row| row.try_get("repo").ok())
462            .unwrap_or_default();
463        let views = load_blockers(&mut conn, task_id, &repo).await?;
464        Ok(views
465            .into_iter()
466            .filter(|view| !view.satisfied)
467            .map(|view| view.label)
468            .collect())
469    }
470
471    pub async fn save_blocker_snapshot(
472        &self,
473        repo: &str,
474        number: i64,
475        snap: &BlockerSnapshot,
476    ) -> anyhow::Result<()> {
477        let raw = serde_json::to_string(snap).context("encode blocker snapshot")?;
478        self.kv_set(&blocker_snapshot_key(repo, number), &raw).await
479    }
480
481    pub async fn blocker_snapshot(
482        &self,
483        repo: &str,
484        number: i64,
485    ) -> anyhow::Result<Option<BlockerSnapshot>> {
486        let Some(raw) = self.kv_get(&blocker_snapshot_key(repo, number)).await? else {
487            return Ok(None);
488        };
489        Ok(serde_json::from_str(&raw).ok())
490    }
491
492    /// Merge an issue state into the stored snapshot. Pull request fields stay.
493    pub async fn note_blocker_issue_state(
494        &self,
495        repo: &str,
496        number: i64,
497        state: &str,
498        reason: Option<&str>,
499    ) -> anyhow::Result<()> {
500        let mut snap = match self.blocker_snapshot(repo, number).await? {
501            Some(existing) => existing,
502            None => BlockerSnapshot::observe(state, reason, None),
503        };
504        snap.note_issue_state(state, reason);
505        self.save_blocker_snapshot(repo, number, &snap).await
506    }
507
508    /// Hold the task when a dependency is open.
509    ///
510    /// `None` means the task may be claimed. `Some` means it is waiting, and the
511    /// vec is the blocking issue labels. The check and the status write share one
512    /// immediate transaction, so two ticks cannot both route the same waiting task.
513    /// `in_progress` and `in_review` are not rewritten
514    pub async fn block_claim_if_waiting(
515        &self,
516        task_id: i64,
517    ) -> anyhow::Result<Option<Vec<String>>> {
518        match self.write(Command::DepBlockClaim { task_id }).await? {
519            Reply::Unmet(unmet) => Ok(unmet),
520            other => anyhow::bail!("block_claim_if_waiting got {other:?}"),
521        }
522    }
523
524    /// Where `unblock` should leave a task. Waiting when any dependency is still open
525    pub async fn status_after_unblock(&self, task_id: i64) -> anyhow::Result<TaskStatus> {
526        match self.block_claim_if_waiting(task_id).await? {
527            Some(_) => Ok(TaskStatus::Waiting),
528            None => Ok(TaskStatus::New),
529        }
530    }
531}
532
533/// Plan-import note that a blocker outside the milestone has no GitHub issue yet
534pub(crate) fn blocked_external_from_body(body: &str) -> Vec<String> {
535    let mut ids = Vec::new();
536    let mut push = |token: &str| {
537        let token = token.trim();
538        if token.is_empty() {
539            return;
540        }
541        if !ids.iter().any(|have| have == token) {
542            ids.push(token.to_string());
543        }
544    };
545    for line in body.lines() {
546        let trimmed = line.trim();
547        if let Some(rest) = trimmed.strip_prefix("<!--") {
548            let rest = rest.trim();
549            let Some(rest) = rest.strip_prefix("orch-blocked-external:") else {
550                continue;
551            };
552            let rest = rest.trim().trim_end_matches("-->").trim();
553            for token in rest.split(',') {
554                push(token);
555            }
556            continue;
557        }
558        let lower = trimmed.to_ascii_lowercase();
559        if !lower.starts_with("blocked-external") {
560            continue;
561        }
562        let rest = trimmed["blocked-external".len()..]
563            .trim_start()
564            .trim_start_matches(':')
565            .trim();
566        let rest = rest.strip_suffix("(no issue yet)").unwrap_or(rest).trim();
567        for token in rest.split(',') {
568            push(token);
569        }
570    }
571    ids
572}
573
574async fn load_blockers(
575    conn: &mut SqliteConnection,
576    task_id: i64,
577    task_repo: &str,
578) -> anyhow::Result<Vec<BlockerView>> {
579    let rows = sqlx::query(
580        "SELECT depends_on_repo, depends_on_number
581         FROM task_dependencies
582         WHERE task_id = ?
583         ORDER BY depends_on_repo, depends_on_number",
584    )
585    .bind(task_id)
586    .fetch_all(&mut *conn)
587    .await?;
588    let mut views = Vec::with_capacity(rows.len());
589    for row in rows {
590        let dep_repo: String = row.try_get("depends_on_repo")?;
591        let number: i64 = row.try_get("depends_on_number")?;
592        let raw: Option<String> = sqlx::query("SELECT value FROM kv WHERE key = ?")
593            .bind(blocker_snapshot_key(&dep_repo, number))
594            .fetch_optional(&mut *conn)
595            .await?
596            .and_then(|found| found.try_get("value").ok());
597        let snap = raw.and_then(|text| serde_json::from_str::<BlockerSnapshot>(&text).ok());
598        views.push(BlockerView {
599            label: dep_label(task_repo, &dep_repo, number),
600            satisfied: snap.as_ref().is_some_and(BlockerSnapshot::is_satisfied),
601        });
602    }
603    Ok(views)
604}
605
606#[cfg(test)]
607mod tests {
608    use sqlx::sqlite::SqliteConnectOptions;
609    use sqlx::sqlite::SqlitePoolOptions;
610
611    use std::fs;
612    use std::path::Path;
613    use std::sync::Arc;
614
615    use super::*;
616    use crate::store::{NewTask, TaskStore};
617
618    async fn task(store: &TaskStore, repo: &str, number: &str, title: &str) -> i64 {
619        task_with_body(store, repo, number, title, "").await
620    }
621
622    async fn task_with_body(
623        store: &TaskStore,
624        repo: &str,
625        number: &str,
626        title: &str,
627        body: &str,
628    ) -> i64 {
629        store
630            .create(&NewTask {
631                external_id: Some(number.to_string()),
632                repo: repo.to_string(),
633                origin: "github".to_string(),
634                title: title.to_string(),
635                body: body.to_string(),
636                source: "manual".to_string(),
637                source_id: String::new(),
638                author: String::new(),
639                url: String::new(),
640                labels: vec![],
641                parent_id: None,
642            })
643            .await
644            .unwrap()
645    }
646
647    fn approved_merge(into_default: bool) -> BlockerSnapshot {
648        BlockerSnapshot::observe(
649            "closed",
650            Some("completed"),
651            Some(&ObservedPullRequest {
652                merged: true,
653                base_is_default: into_default,
654                approved: true,
655                closed_unmerged: false,
656            }),
657        )
658    }
659
660    #[tokio::test]
661    async fn unmet_dependency_demotes_new_and_promotes_when_the_pr_is_done() {
662        let store = TaskStore::open_memory().await.unwrap();
663        let a = task(&store, "owner/repo", "1", "A").await;
664        let b = task(&store, "owner/repo", "2", "B").await;
665        store
666            .set_fields(
667                b,
668                &[
669                    ("branch", serde_json::json!("old-branch")),
670                    ("worktree", serde_json::json!("/tmp/old")),
671                ],
672            )
673            .await
674            .unwrap();
675        store
676            .replace_dependencies(
677                b,
678                &[NewDependency {
679                    repo: "owner/repo".to_string(),
680                    number: 1,
681                    source: DepSource::Github,
682                }],
683            )
684            .await
685            .unwrap();
686
687        let held = store.block_claim_if_waiting(b).await.unwrap();
688        assert_eq!(held, Some(vec!["#1".to_string()]));
689        assert_eq!(store.get(b).await.unwrap().status, TaskStatus::Waiting);
690
691        store.update_status(a, TaskStatus::Done).await.unwrap();
692        assert!(store.block_claim_if_waiting(b).await.unwrap().is_some());
693        assert_eq!(store.get(b).await.unwrap().status, TaskStatus::Waiting);
694
695        store
696            .save_blocker_snapshot("owner/repo", 1, &approved_merge(true))
697            .await
698            .unwrap();
699        let released = store.block_claim_if_waiting(b).await.unwrap();
700        assert_eq!(released, None);
701        let task = store.get(b).await.unwrap();
702        assert_eq!(task.status, TaskStatus::New);
703        assert_eq!(task.branch, "");
704        assert_eq!(task.worktree, "");
705        assert_eq!(
706            store
707                .kv_get(&worktree_refresh_key(b))
708                .await
709                .unwrap()
710                .as_deref(),
711            Some("1")
712        );
713    }
714
715    #[tokio::test]
716    async fn not_planned_beats_a_merged_pr_and_a_completed_issue_with_no_pr_is_done() {
717        let store = TaskStore::open_memory().await.unwrap();
718        let a = task(&store, "owner/repo", "1", "A").await;
719        let b = task(&store, "owner/repo", "2", "B").await;
720        store.update_status(a, TaskStatus::Done).await.unwrap();
721        let mut not_planned = approved_merge(true);
722        not_planned.state_reason = Some("not_planned".to_string());
723        store
724            .save_blocker_snapshot("owner/repo", 1, &not_planned)
725            .await
726            .unwrap();
727        store
728            .replace_dependencies(
729                b,
730                &[NewDependency {
731                    repo: "owner/repo".to_string(),
732                    number: 1,
733                    source: DepSource::Github,
734                }],
735            )
736            .await
737            .unwrap();
738        assert!(store.block_claim_if_waiting(b).await.unwrap().is_some());
739
740        let c = task(&store, "owner/repo", "3", "C").await;
741        store
742            .save_blocker_snapshot(
743                "other/repo",
744                9,
745                &BlockerSnapshot::observe("closed", Some("completed"), None),
746            )
747            .await
748            .unwrap();
749        store
750            .replace_dependencies(
751                c,
752                &[NewDependency {
753                    repo: "other/repo".to_string(),
754                    number: 9,
755                    source: DepSource::Body,
756                }],
757            )
758            .await
759            .unwrap();
760        assert_eq!(store.block_claim_if_waiting(c).await.unwrap(), None);
761    }
762
763    #[test]
764    fn pull_request_completion_cases() {
765        let open = BlockerSnapshot::observe("open", None, None);
766        assert!(!open.is_satisfied());
767
768        let pr_open = BlockerSnapshot::observe(
769            "open",
770            None,
771            Some(&ObservedPullRequest {
772                merged: false,
773                base_is_default: true,
774                approved: false,
775                closed_unmerged: false,
776            }),
777        );
778        assert!(!pr_open.is_satisfied());
779
780        let approved_open = BlockerSnapshot::observe(
781            "open",
782            None,
783            Some(&ObservedPullRequest {
784                merged: false,
785                base_is_default: true,
786                approved: true,
787                closed_unmerged: false,
788            }),
789        );
790        assert!(!approved_open.is_satisfied());
791
792        assert!(approved_merge(true).is_satisfied());
793        assert!(!approved_merge(false).is_satisfied());
794
795        let closed_unmerged = BlockerSnapshot::observe(
796            "closed",
797            Some("completed"),
798            Some(&ObservedPullRequest {
799                merged: false,
800                base_is_default: true,
801                approved: true,
802                closed_unmerged: true,
803            }),
804        );
805        assert!(!closed_unmerged.is_satisfied());
806        assert_eq!(
807            closed_unmerged.unsatisfied_reason().as_deref(),
808            Some("closed, PR closed unmerged")
809        );
810        assert_eq!(open.unsatisfied_reason().as_deref(), Some("open"));
811        assert_eq!(approved_merge(true).unsatisfied_reason(), None);
812
813        let merged_unapproved = BlockerSnapshot::observe(
814            "closed",
815            Some("completed"),
816            Some(&ObservedPullRequest {
817                merged: true,
818                base_is_default: true,
819                approved: false,
820                closed_unmerged: false,
821            }),
822        );
823        assert!(merged_unapproved.is_satisfied());
824
825        assert!(BlockerSnapshot::observe("closed", None, None).is_satisfied());
826        assert!(BlockerSnapshot::observe("closed", Some("completed"), None).is_satisfied());
827        assert!(!BlockerSnapshot::observe("closed", Some("not_planned"), None).is_satisfied());
828        assert!(!BlockerSnapshot::observe("closed", Some("duplicate"), None).is_satisfied());
829    }
830
831    #[tokio::test]
832    async fn blocked_and_duplicate_stay_unmet_and_in_progress_is_not_rewritten() {
833        let store = TaskStore::open_memory().await.unwrap();
834        let a = task(&store, "owner/repo", "1", "A").await;
835        let b = task(&store, "owner/repo", "2", "B").await;
836        store.update_status(a, TaskStatus::Blocked).await.unwrap();
837        store
838            .replace_dependencies(
839                b,
840                &[NewDependency {
841                    repo: "owner/repo".to_string(),
842                    number: 1,
843                    source: DepSource::Cli,
844                }],
845            )
846            .await
847            .unwrap();
848        store
849            .update_status(b, TaskStatus::InProgress)
850            .await
851            .unwrap();
852        assert!(store.block_claim_if_waiting(b).await.unwrap().is_some());
853        assert_eq!(store.get(b).await.unwrap().status, TaskStatus::InProgress);
854
855        store
856            .save_blocker_snapshot(
857                "owner/repo",
858                1,
859                &BlockerSnapshot::observe("closed", Some("duplicate"), None),
860            )
861            .await
862            .unwrap();
863        store.update_status(a, TaskStatus::Done).await.unwrap();
864        assert!(store.block_claim_if_waiting(b).await.unwrap().is_some());
865        assert_eq!(store.get(b).await.unwrap().status, TaskStatus::InProgress);
866    }
867
868    #[tokio::test]
869    async fn concurrent_claims_both_report_waiting() {
870        let store = Arc::new(TaskStore::open_memory().await.unwrap());
871        let b = task(&store, "owner/repo", "2", "B").await;
872        store
873            .replace_dependencies(
874                b,
875                &[NewDependency {
876                    repo: "owner/repo".to_string(),
877                    number: 1,
878                    source: DepSource::Github,
879                }],
880            )
881            .await
882            .unwrap();
883        let left = Arc::clone(&store);
884        let right = Arc::clone(&store);
885        let (a, c) = tokio::join!(
886            left.block_claim_if_waiting(b),
887            right.block_claim_if_waiting(b)
888        );
889        assert!(a.unwrap().is_some());
890        assert!(c.unwrap().is_some());
891        assert_eq!(store.get(b).await.unwrap().status, TaskStatus::Waiting);
892    }
893
894    #[tokio::test]
895    async fn unblock_selection_skips_waiting_and_blocked_with_deps_stays_waiting() {
896        let store = TaskStore::open_memory().await.unwrap();
897        let a = task(&store, "owner/repo", "1", "A").await;
898        let b = task(&store, "owner/repo", "2", "B").await;
899        store.update_status(a, TaskStatus::Blocked).await.unwrap();
900        store
901            .replace_dependencies(
902                b,
903                &[NewDependency {
904                    repo: "owner/repo".to_string(),
905                    number: 1,
906                    source: DepSource::Github,
907                }],
908            )
909            .await
910            .unwrap();
911        store.block_claim_if_waiting(b).await.unwrap();
912        let blocked = store
913            .list_by_status("owner/repo", TaskStatus::Blocked)
914            .await
915            .unwrap();
916        assert!(blocked.iter().all(|task| task.id != b));
917        assert_eq!(store.get(b).await.unwrap().status, TaskStatus::Waiting);
918        assert_eq!(
919            store.status_after_unblock(a).await.unwrap(),
920            TaskStatus::New
921        );
922        store.update_status(a, TaskStatus::Blocked).await.unwrap();
923        store
924            .replace_dependencies(
925                a,
926                &[NewDependency {
927                    repo: "owner/repo".to_string(),
928                    number: 9,
929                    source: DepSource::Cli,
930                }],
931            )
932            .await
933            .unwrap();
934        assert_eq!(
935            store.status_after_unblock(a).await.unwrap(),
936            TaskStatus::Waiting
937        );
938    }
939
940    #[tokio::test]
941    async fn entering_waiting_clears_a_stored_branch() {
942        let store = TaskStore::open_memory().await.unwrap();
943        let b = task(&store, "owner/repo", "2", "B").await;
944        store
945            .set_fields(
946                b,
947                &[
948                    ("branch", serde_json::json!("old-branch")),
949                    ("worktree", serde_json::json!("/tmp/old")),
950                ],
951            )
952            .await
953            .unwrap();
954        store
955            .replace_dependencies(
956                b,
957                &[NewDependency {
958                    repo: "owner/repo".to_string(),
959                    number: 1,
960                    source: DepSource::Github,
961                }],
962            )
963            .await
964            .unwrap();
965        store.block_claim_if_waiting(b).await.unwrap();
966        let task = store.get(b).await.unwrap();
967        assert_eq!(task.status, TaskStatus::Waiting);
968        assert_eq!(task.branch, "");
969        assert_eq!(task.worktree, "");
970    }
971
972    #[test]
973    fn blocked_external_from_body_reads_the_note_and_the_marker() {
974        let body = concat!(
975            "blocked-external: M1-05, M1-01 (no issue yet)\n\n",
976            "<!-- orch-blocked-external: M1-05, M1-01 -->\n",
977            "<!-- orch-plan: M6-01 -->\n",
978        );
979        assert_eq!(
980            blocked_external_from_body(body),
981            vec!["M1-05".to_string(), "M1-01".to_string()]
982        );
983        let blocked = blocked_external_from_body("<!-- orch-plan: M6-01 -->");
984        assert!(blocked.is_empty(), "{blocked:?}");
985    }
986
987    #[tokio::test]
988    async fn blocked_external_note_holds_the_task_and_clears_when_removed() {
989        let store = TaskStore::open_memory().await.unwrap();
990        let body = concat!(
991            "blocked-external: M3-01 (no issue yet)\n\n",
992            "<!-- orch-blocked-external: M3-01 -->\n",
993        );
994        let b = task_with_body(&store, "owner/repo", "4", "M4-01 API", body).await;
995        let held = store.block_claim_if_waiting(b).await.unwrap();
996        assert_eq!(held, Some(vec!["M3-01".to_string()]));
997        assert_eq!(store.get(b).await.unwrap().status, TaskStatus::Waiting);
998
999        store
1000            .upsert_external(&crate::store::UpsertExternal {
1001                repo: "owner/repo",
1002                ext_id: "4",
1003                title: "M4-01 API",
1004                body: "<!-- orch-plan: M4-01 -->\n",
1005                author: "",
1006                url: "",
1007                labels: &[],
1008                origin: "github",
1009            })
1010            .await
1011            .unwrap();
1012        let released = store.block_claim_if_waiting(b).await.unwrap();
1013        assert_eq!(released, None);
1014        assert_eq!(store.get(b).await.unwrap().status, TaskStatus::New);
1015    }
1016
1017    #[tokio::test]
1018    async fn migration_028_applies_over_existing_tasks() {
1019        let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("migrations");
1020        let options = SqliteConnectOptions::new()
1021            .filename(":memory:")
1022            .pragma("foreign_keys", "ON");
1023        let pool = SqlitePoolOptions::new()
1024            .max_connections(1)
1025            .connect_with(options)
1026            .await
1027            .unwrap();
1028        let mut files: Vec<_> = fs::read_dir(&dir)
1029            .unwrap()
1030            .map(|entry| entry.unwrap().path())
1031            .filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("sql"))
1032            .collect();
1033        files.sort();
1034        for path in &files {
1035            let name = path.file_name().unwrap().to_string_lossy();
1036            if name.starts_with("028") {
1037                continue;
1038            }
1039            let sql = fs::read_to_string(path).unwrap();
1040            sqlx::raw_sql(sqlx::AssertSqlSafe(sql.as_str()))
1041                .execute(&pool)
1042                .await
1043                .unwrap_or_else(|err| panic!("migration {name} failed: {err}"));
1044        }
1045        sqlx::query("INSERT INTO tasks (repo, title) VALUES ('owner/repo', 'existing')")
1046            .execute(&pool)
1047            .await
1048            .unwrap();
1049        let sql = fs::read_to_string(dir.join("028_task_dependencies.sql")).unwrap();
1050        sqlx::raw_sql(sqlx::AssertSqlSafe(sql.as_str()))
1051            .execute(&pool)
1052            .await
1053            .unwrap();
1054        let (id, title): (i64, String) = sqlx::query_as("SELECT id, title FROM tasks")
1055            .fetch_one(&pool)
1056            .await
1057            .unwrap();
1058        assert_eq!(title, "existing");
1059        sqlx::query(
1060            "INSERT INTO task_dependencies (task_id, depends_on_repo, depends_on_number, source)
1061             VALUES (?, 'owner/repo', 1, 'cli')",
1062        )
1063        .bind(id)
1064        .execute(&pool)
1065        .await
1066        .unwrap();
1067        let (count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM task_dependencies")
1068            .fetch_one(&pool)
1069            .await
1070            .unwrap();
1071        assert_eq!(count, 1);
1072    }
1073}