1use super::{TaskStatus, TaskStore};
15use anyhow::Context;
16use sqlx::Row;
17use sqlx::SqliteConnection;
18use std::collections::HashMap;
19
20#[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#[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#[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 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
181pub 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
217pub fn blocker_is_satisfied(snapshot: Option<&BlockerSnapshot>) -> bool {
219 snapshot.is_some_and(BlockerSnapshot::is_satisfied)
220}
221
222pub 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 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 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 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 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 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::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 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
614pub 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, ¬_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}