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