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