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