1use super::{TaskStatus, TaskStore};
13use crate::cluster::Command;
14use anyhow::Context;
15use sqlx::Row;
16use sqlx::SqliteConnection;
17use std::collections::HashMap;
18
19#[derive(Debug, thiserror::Error)]
23#[error("waiting on {}", .blockers.join(", "))]
24pub struct WaitingOnDependency {
25 pub blockers: Vec<String>,
26}
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
29pub enum DepSource {
30 Github,
31 Body,
32 Cli,
33}
34
35impl DepSource {
36 pub fn as_str(self) -> &'static str {
37 match self {
38 Self::Github => "github",
39 Self::Body => "body",
40 Self::Cli => "cli",
41 }
42 }
43
44 pub fn parse(value: &str) -> Option<Self> {
45 match value {
46 "github" => Some(Self::Github),
47 "body" => Some(Self::Body),
48 "cli" => Some(Self::Cli),
49 _ => None,
50 }
51 }
52}
53
54#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
55pub struct NewDependency {
56 pub repo: String,
57 pub number: i64,
58 pub source: DepSource,
59}
60
61#[derive(Debug, Clone)]
62pub struct TaskDependency {
63 pub task_id: i64,
64 pub depends_on_task_id: Option<i64>,
65 pub depends_on_repo: String,
66 pub depends_on_number: i64,
67 pub source: String,
68 pub blocker_status: Option<String>,
69}
70
71#[derive(Debug, Clone)]
72pub struct SchedulableTask {
73 pub id: i64,
74 pub repo: String,
75 pub status: String,
76 pub external_id: Option<String>,
77 pub body: String,
78 pub title: String,
79}
80
81#[derive(Debug, Clone)]
82pub struct DepEdge {
83 pub task_id: i64,
84 pub task_repo: String,
85 pub task_external_id: Option<String>,
86 pub task_title: String,
87 pub depends_on_repo: String,
88 pub depends_on_number: i64,
89}
90
91#[derive(Debug, Clone)]
92struct BlockerView {
93 label: String,
94 satisfied: bool,
95}
96
97#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
102#[expect(
103 clippy::struct_excessive_bools,
104 reason = "each bool mirrors a github field and the struct is stored as json"
105)]
106pub struct ObservedPullRequest {
107 pub merged: bool,
108 pub base_is_default: bool,
109 pub approved: bool,
110 pub closed_unmerged: bool,
111}
112
113#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
117#[expect(
118 clippy::struct_excessive_bools,
119 reason = "each bool mirrors a github field and the struct is stored as json"
120)]
121pub struct BlockerSnapshot {
122 pub issue_state: String,
123 pub state_reason: Option<String>,
124 pub has_pr: bool,
125 pub pr_merged: bool,
126 pub pr_merged_into_default: bool,
127 pub pr_approved: bool,
128 pub pr_closed: bool,
129}
130
131impl BlockerSnapshot {
132 pub fn observe(
133 issue_state: &str,
134 state_reason: Option<&str>,
135 pr: Option<&ObservedPullRequest>,
136 ) -> Self {
137 let normalized = normalize_close_reason(issue_state, state_reason);
138 let (has_pr, merged, into_default, approved, pr_closed) = match pr {
139 Some(pr) => (
140 true,
141 pr.merged,
142 pr.base_is_default,
143 pr.approved,
144 pr.closed_unmerged,
145 ),
146 None => (false, false, false, false, false),
147 };
148 Self {
149 issue_state: if issue_state.eq_ignore_ascii_case("closed") {
150 "closed".to_string()
151 } else {
152 "open".to_string()
153 },
154 state_reason: Some(normalized.to_string()),
155 has_pr,
156 pr_merged: merged,
157 pr_merged_into_default: into_default,
158 pr_approved: approved,
159 pr_closed,
160 }
161 }
162
163 pub fn note_issue_state(&mut self, issue_state: &str, state_reason: Option<&str>) {
167 let normalized = normalize_close_reason(issue_state, state_reason);
168 self.issue_state = if issue_state.eq_ignore_ascii_case("closed") {
169 "closed".to_string()
170 } else {
171 "open".to_string()
172 };
173 self.state_reason = Some(normalized.to_string());
174 }
175
176 pub fn is_satisfied(&self) -> bool {
177 let reason = self.state_reason.as_deref().unwrap_or("");
178 if reason == "not_planned" || reason == "duplicate" {
179 return false;
180 }
181 if self.has_pr {
182 return self.pr_merged && self.pr_merged_into_default;
184 }
185 self.issue_state == "closed" && (reason.is_empty() || reason == "completed")
186 }
187
188 pub fn unsatisfied_reason(&self) -> Option<String> {
190 if self.is_satisfied() {
191 return None;
192 }
193 let reason = self.state_reason.as_deref().unwrap_or("");
194 if self.issue_state != "closed" {
195 return Some(if self.has_pr {
196 "open, PR not merged".to_string()
197 } else {
198 "open".to_string()
199 });
200 }
201 if reason == "not_planned" || reason == "duplicate" {
202 return Some(format!("closed as {reason}"));
203 }
204 if self.pr_closed {
205 return Some("closed, PR closed unmerged".to_string());
206 }
207 if self.has_pr {
208 return Some(if self.pr_merged {
209 "closed, PR merged outside the default branch".to_string()
210 } else {
211 "closed, PR not merged".to_string()
212 });
213 }
214 Some("closed".to_string())
215 }
216}
217
218pub(crate) fn normalize_close_reason(state: &str, state_reason: Option<&str>) -> &'static str {
222 if !state.eq_ignore_ascii_case("closed") {
223 return "open";
224 }
225 match state_reason
226 .unwrap_or("")
227 .trim()
228 .to_ascii_lowercase()
229 .as_str()
230 {
231 "not_planned" => "not_planned",
232 "duplicate" => "duplicate",
233 "reopened" => "open",
234 _ => "completed",
235 }
236}
237
238pub fn blocker_snapshot_key(repo: &str, number: i64) -> String {
239 format!("dep_blocker:{repo}#{number}")
240}
241
242pub fn worktree_refresh_key(task_id: i64) -> String {
243 format!("dep_worktree_refresh:{task_id}")
244}
245
246pub fn dep_label(task_repo: &str, dep_repo: &str, number: i64) -> String {
247 if task_repo == dep_repo {
248 format!("#{number}")
249 } else {
250 format!("{dep_repo}#{number}")
251 }
252}
253
254pub fn blocker_is_satisfied(snapshot: Option<&BlockerSnapshot>) -> bool {
256 snapshot.is_some_and(BlockerSnapshot::is_satisfied)
257}
258
259pub fn blocker_failure_kind(
263 task_status: Option<&str>,
264 snapshot: Option<&BlockerSnapshot>,
265) -> Option<&'static str> {
266 let snap = snapshot?;
267 if snap.state_reason.as_deref() == Some("not_planned") {
268 return Some("not_planned");
269 }
270 if task_status == Some("blocked") && !snap.is_satisfied() {
271 return Some("blocked");
272 }
273 None
274}
275
276impl TaskStore {
277 pub async fn move_repo(&self, old: &str, new: &str) -> anyhow::Result<u64> {
281 let cmd = Command::MoveRepo {
282 old: old.to_string(),
283 new: new.to_string(),
284 };
285 Ok(u64::try_from(self.write_int(cmd).await?)?)
286 }
287
288 pub async fn replace_dependencies(
290 &self,
291 task_id: i64,
292 rows: &[NewDependency],
293 ) -> anyhow::Result<()> {
294 let cmd = Command::DepReplace {
295 task_id,
296 rows: rows.to_vec(),
297 };
298 self.write(cmd).await.map(|_| ())
299 }
300
301 pub async fn link_known_task(
303 &self,
304 task_id: i64,
305 repo: &str,
306 number: i64,
307 ) -> anyhow::Result<()> {
308 let cmd = Command::DepLinkKnown {
309 task_id,
310 repo: repo.to_string(),
311 number,
312 };
313 self.write(cmd).await.map(|_| ())
314 }
315
316 pub async fn list_dependencies(&self, task_id: i64) -> anyhow::Result<Vec<TaskDependency>> {
317 let rows = sqlx::query(
318 "SELECT d.task_id, d.depends_on_task_id, d.depends_on_repo, d.depends_on_number, d.source,
319 t.status AS blocker_status
320 FROM task_dependencies d
321 LEFT JOIN tasks t
322 ON t.repo = d.depends_on_repo
323 AND t.external_id = CAST(d.depends_on_number AS TEXT)
324 WHERE d.task_id = ?
325 ORDER BY d.depends_on_repo, d.depends_on_number",
326 )
327 .bind(task_id)
328 .fetch_all(&self.pool)
329 .await?;
330 let mut out = Vec::with_capacity(rows.len());
331 for row in rows {
332 out.push(TaskDependency {
333 task_id: row.try_get("task_id")?,
334 depends_on_task_id: row.try_get("depends_on_task_id")?,
335 depends_on_repo: row.try_get("depends_on_repo")?,
336 depends_on_number: row.try_get("depends_on_number")?,
337 source: row.try_get("source")?,
338 blocker_status: row.try_get("blocker_status")?,
339 });
340 }
341 Ok(out)
342 }
343
344 pub async fn list_dependents(
345 &self,
346 repo: &str,
347 number: i64,
348 ) -> anyhow::Result<Vec<TaskDependency>> {
349 let rows = sqlx::query(
350 "SELECT d.task_id, d.depends_on_task_id, d.depends_on_repo, d.depends_on_number, d.source,
351 t.status AS blocker_status
352 FROM task_dependencies d
353 JOIN tasks t ON t.id = d.task_id
354 WHERE d.depends_on_repo = ? AND d.depends_on_number = ?
355 ORDER BY d.task_id",
356 )
357 .bind(repo)
358 .bind(number)
359 .fetch_all(&self.pool)
360 .await?;
361 let mut out = Vec::with_capacity(rows.len());
362 for row in rows {
363 out.push(TaskDependency {
364 task_id: row.try_get("task_id")?,
365 depends_on_task_id: row.try_get("depends_on_task_id")?,
366 depends_on_repo: row.try_get("depends_on_repo")?,
367 depends_on_number: row.try_get("depends_on_number")?,
368 source: row.try_get("source")?,
369 blocker_status: row.try_get("blocker_status")?,
370 });
371 }
372 Ok(out)
373 }
374
375 pub async fn list_schedulable(&self) -> anyhow::Result<Vec<SchedulableTask>> {
376 let rows = sqlx::query(
377 "SELECT id, repo, status, external_id, body, title
378 FROM tasks
379 WHERE status IN ('new', 'routed', 'waiting')
380 ORDER BY id",
381 )
382 .fetch_all(&self.pool)
383 .await?;
384 let mut out = Vec::with_capacity(rows.len());
385 for row in rows {
386 out.push(SchedulableTask {
387 id: row.try_get("id")?,
388 repo: row.try_get("repo")?,
389 status: row.try_get("status")?,
390 external_id: row.try_get("external_id")?,
391 body: row.try_get("body")?,
392 title: row.try_get("title")?,
393 });
394 }
395 Ok(out)
396 }
397
398 pub async fn list_dependency_edges(&self) -> anyhow::Result<Vec<DepEdge>> {
399 let rows = sqlx::query(
400 "SELECT d.task_id, t.repo AS task_repo, t.external_id AS task_external_id, t.title AS task_title,
401 d.depends_on_repo, d.depends_on_number
402 FROM task_dependencies d
403 JOIN tasks t ON t.id = d.task_id",
404 )
405 .fetch_all(&self.pool)
406 .await?;
407 let mut out = Vec::with_capacity(rows.len());
408 for row in rows {
409 out.push(DepEdge {
410 task_id: row.try_get("task_id")?,
411 task_repo: row.try_get("task_repo")?,
412 task_external_id: row.try_get("task_external_id")?,
413 task_title: row.try_get("task_title")?,
414 depends_on_repo: row.try_get("depends_on_repo")?,
415 depends_on_number: row.try_get("depends_on_number")?,
416 });
417 }
418 Ok(out)
419 }
420
421 pub async fn unmet_labels_by_task(&self) -> anyhow::Result<HashMap<i64, Vec<String>>> {
423 let mut conn = self.pool.acquire().await?;
424 let ids: Vec<i64> = sqlx::query("SELECT DISTINCT task_id FROM task_dependencies")
425 .fetch_all(&mut *conn)
426 .await?
427 .into_iter()
428 .map(|row| row.try_get("task_id"))
429 .collect::<Result<Vec<_>, _>>()?;
430 let mut out = HashMap::new();
431 for task_id in ids {
432 let repo: String = sqlx::query("SELECT repo FROM tasks WHERE id = ?")
433 .bind(task_id)
434 .fetch_optional(&mut *conn)
435 .await?
436 .and_then(|row| row.try_get("repo").ok())
437 .unwrap_or_default();
438 let views = load_blockers(&mut conn, task_id, &repo).await?;
439 let labels: Vec<String> = views
440 .into_iter()
441 .filter(|view| !view.satisfied)
442 .map(|view| view.label)
443 .collect();
444 if !labels.is_empty() {
445 out.insert(task_id, labels);
446 }
447 }
448 Ok(out)
449 }
450
451 pub async fn unmet_blocker_labels(&self, task_id: i64) -> anyhow::Result<Vec<String>> {
452 let mut conn = self.pool.acquire().await?;
453 let repo: String = sqlx::query("SELECT repo FROM tasks WHERE id = ?")
454 .bind(task_id)
455 .fetch_optional(&mut *conn)
456 .await?
457 .and_then(|row| row.try_get("repo").ok())
458 .unwrap_or_default();
459 let views = load_blockers(&mut conn, task_id, &repo).await?;
460 Ok(views
461 .into_iter()
462 .filter(|view| !view.satisfied)
463 .map(|view| view.label)
464 .collect())
465 }
466
467 pub async fn save_blocker_snapshot(
468 &self,
469 repo: &str,
470 number: i64,
471 snap: &BlockerSnapshot,
472 ) -> anyhow::Result<()> {
473 let raw = serde_json::to_string(snap).context("encode blocker snapshot")?;
474 self.kv_set(&blocker_snapshot_key(repo, number), &raw).await
475 }
476
477 pub async fn blocker_snapshot(
478 &self,
479 repo: &str,
480 number: i64,
481 ) -> anyhow::Result<Option<BlockerSnapshot>> {
482 let Some(raw) = self.kv_get(&blocker_snapshot_key(repo, number)).await? else {
483 return Ok(None);
484 };
485 Ok(serde_json::from_str(&raw).ok())
486 }
487
488 pub async fn note_blocker_issue_state(
490 &self,
491 repo: &str,
492 number: i64,
493 state: &str,
494 reason: Option<&str>,
495 ) -> anyhow::Result<()> {
496 let mut snap = match self.blocker_snapshot(repo, number).await? {
497 Some(existing) => existing,
498 None => BlockerSnapshot::observe(state, reason, None),
499 };
500 snap.note_issue_state(state, reason);
501 self.save_blocker_snapshot(repo, number, &snap).await
502 }
503
504 pub async fn block_claim_if_waiting(
511 &self,
512 task_id: i64,
513 ) -> anyhow::Result<Option<Vec<String>>> {
514 match self.write(Command::DepBlockClaim { task_id }).await? {
515 crate::cluster::Reply::Unmet(unmet) => Ok(unmet),
516 other => anyhow::bail!("block_claim_if_waiting got {other:?}"),
517 }
518 }
519
520 pub async fn status_after_unblock(&self, task_id: i64) -> anyhow::Result<TaskStatus> {
522 match self.block_claim_if_waiting(task_id).await? {
523 Some(_) => Ok(TaskStatus::Waiting),
524 None => Ok(TaskStatus::New),
525 }
526 }
527}
528
529pub(crate) fn blocked_external_from_body(body: &str) -> Vec<String> {
531 let mut ids = Vec::new();
532 let mut push = |token: &str| {
533 let token = token.trim();
534 if token.is_empty() {
535 return;
536 }
537 if !ids.iter().any(|have| have == token) {
538 ids.push(token.to_string());
539 }
540 };
541 for line in body.lines() {
542 let trimmed = line.trim();
543 if let Some(rest) = trimmed.strip_prefix("<!--") {
544 let rest = rest.trim();
545 let Some(rest) = rest.strip_prefix("orch-blocked-external:") else {
546 continue;
547 };
548 let rest = rest.trim().trim_end_matches("-->").trim();
549 for token in rest.split(',') {
550 push(token);
551 }
552 continue;
553 }
554 let lower = trimmed.to_ascii_lowercase();
555 if !lower.starts_with("blocked-external") {
556 continue;
557 }
558 let rest = trimmed["blocked-external".len()..]
559 .trim_start()
560 .trim_start_matches(':')
561 .trim();
562 let rest = rest.strip_suffix("(no issue yet)").unwrap_or(rest).trim();
563 for token in rest.split(',') {
564 push(token);
565 }
566 }
567 ids
568}
569
570async fn load_blockers(
571 conn: &mut SqliteConnection,
572 task_id: i64,
573 task_repo: &str,
574) -> anyhow::Result<Vec<BlockerView>> {
575 let rows = sqlx::query(
576 "SELECT depends_on_repo, depends_on_number
577 FROM task_dependencies
578 WHERE task_id = ?
579 ORDER BY depends_on_repo, depends_on_number",
580 )
581 .bind(task_id)
582 .fetch_all(&mut *conn)
583 .await?;
584 let mut views = Vec::with_capacity(rows.len());
585 for row in rows {
586 let dep_repo: String = row.try_get("depends_on_repo")?;
587 let number: i64 = row.try_get("depends_on_number")?;
588 let raw: Option<String> = sqlx::query("SELECT value FROM kv WHERE key = ?")
589 .bind(blocker_snapshot_key(&dep_repo, number))
590 .fetch_optional(&mut *conn)
591 .await?
592 .and_then(|found| found.try_get("value").ok());
593 let snap = raw.and_then(|text| serde_json::from_str::<BlockerSnapshot>(&text).ok());
594 views.push(BlockerView {
595 label: dep_label(task_repo, &dep_repo, number),
596 satisfied: snap.as_ref().is_some_and(BlockerSnapshot::is_satisfied),
597 });
598 }
599 Ok(views)
600}
601
602#[cfg(test)]
603mod tests {
604 use super::*;
605 use crate::store::{NewTask, TaskStore};
606
607 async fn task(store: &TaskStore, repo: &str, number: &str, title: &str) -> i64 {
608 task_with_body(store, repo, number, title, "").await
609 }
610
611 async fn task_with_body(
612 store: &TaskStore,
613 repo: &str,
614 number: &str,
615 title: &str,
616 body: &str,
617 ) -> i64 {
618 store
619 .create(&NewTask {
620 external_id: Some(number.to_string()),
621 repo: repo.to_string(),
622 origin: "github".to_string(),
623 title: title.to_string(),
624 body: body.to_string(),
625 source: "manual".to_string(),
626 source_id: String::new(),
627 author: String::new(),
628 url: String::new(),
629 labels: vec![],
630 parent_id: None,
631 })
632 .await
633 .unwrap()
634 }
635
636 fn approved_merge(into_default: bool) -> BlockerSnapshot {
637 BlockerSnapshot::observe(
638 "closed",
639 Some("completed"),
640 Some(&ObservedPullRequest {
641 merged: true,
642 base_is_default: into_default,
643 approved: true,
644 closed_unmerged: false,
645 }),
646 )
647 }
648
649 #[tokio::test]
650 async fn unmet_dependency_demotes_new_and_promotes_when_the_pr_is_done() {
651 let store = TaskStore::open_memory().await.unwrap();
652 let a = task(&store, "owner/repo", "1", "A").await;
653 let b = task(&store, "owner/repo", "2", "B").await;
654 store
655 .set_fields(
656 b,
657 &[
658 ("branch", serde_json::json!("old-branch")),
659 ("worktree", serde_json::json!("/tmp/old")),
660 ],
661 )
662 .await
663 .unwrap();
664 store
665 .replace_dependencies(
666 b,
667 &[NewDependency {
668 repo: "owner/repo".to_string(),
669 number: 1,
670 source: DepSource::Github,
671 }],
672 )
673 .await
674 .unwrap();
675
676 let held = store.block_claim_if_waiting(b).await.unwrap();
677 assert_eq!(held, Some(vec!["#1".to_string()]));
678 assert_eq!(store.get(b).await.unwrap().status, TaskStatus::Waiting);
679
680 store.update_status(a, TaskStatus::Done).await.unwrap();
681 assert!(store.block_claim_if_waiting(b).await.unwrap().is_some());
682 assert_eq!(store.get(b).await.unwrap().status, TaskStatus::Waiting);
683
684 store
685 .save_blocker_snapshot("owner/repo", 1, &approved_merge(true))
686 .await
687 .unwrap();
688 let released = store.block_claim_if_waiting(b).await.unwrap();
689 assert_eq!(released, None);
690 let task = store.get(b).await.unwrap();
691 assert_eq!(task.status, TaskStatus::New);
692 assert_eq!(task.branch, "");
693 assert_eq!(task.worktree, "");
694 assert_eq!(
695 store
696 .kv_get(&worktree_refresh_key(b))
697 .await
698 .unwrap()
699 .as_deref(),
700 Some("1")
701 );
702 }
703
704 #[tokio::test]
705 async fn not_planned_beats_a_merged_pr_and_a_completed_issue_with_no_pr_is_done() {
706 let store = TaskStore::open_memory().await.unwrap();
707 let a = task(&store, "owner/repo", "1", "A").await;
708 let b = task(&store, "owner/repo", "2", "B").await;
709 store.update_status(a, TaskStatus::Done).await.unwrap();
710 let mut not_planned = approved_merge(true);
711 not_planned.state_reason = Some("not_planned".to_string());
712 store
713 .save_blocker_snapshot("owner/repo", 1, ¬_planned)
714 .await
715 .unwrap();
716 store
717 .replace_dependencies(
718 b,
719 &[NewDependency {
720 repo: "owner/repo".to_string(),
721 number: 1,
722 source: DepSource::Github,
723 }],
724 )
725 .await
726 .unwrap();
727 assert!(store.block_claim_if_waiting(b).await.unwrap().is_some());
728
729 let c = task(&store, "owner/repo", "3", "C").await;
730 store
731 .save_blocker_snapshot(
732 "other/repo",
733 9,
734 &BlockerSnapshot::observe("closed", Some("completed"), None),
735 )
736 .await
737 .unwrap();
738 store
739 .replace_dependencies(
740 c,
741 &[NewDependency {
742 repo: "other/repo".to_string(),
743 number: 9,
744 source: DepSource::Body,
745 }],
746 )
747 .await
748 .unwrap();
749 assert_eq!(store.block_claim_if_waiting(c).await.unwrap(), None);
750 }
751
752 #[test]
753 fn pull_request_completion_cases() {
754 let open = BlockerSnapshot::observe("open", None, None);
755 assert!(!open.is_satisfied());
756
757 let pr_open = BlockerSnapshot::observe(
758 "open",
759 None,
760 Some(&ObservedPullRequest {
761 merged: false,
762 base_is_default: true,
763 approved: false,
764 closed_unmerged: false,
765 }),
766 );
767 assert!(!pr_open.is_satisfied());
768
769 let approved_open = BlockerSnapshot::observe(
770 "open",
771 None,
772 Some(&ObservedPullRequest {
773 merged: false,
774 base_is_default: true,
775 approved: true,
776 closed_unmerged: false,
777 }),
778 );
779 assert!(!approved_open.is_satisfied());
780
781 assert!(approved_merge(true).is_satisfied());
782 assert!(!approved_merge(false).is_satisfied());
783
784 let closed_unmerged = BlockerSnapshot::observe(
785 "closed",
786 Some("completed"),
787 Some(&ObservedPullRequest {
788 merged: false,
789 base_is_default: true,
790 approved: true,
791 closed_unmerged: true,
792 }),
793 );
794 assert!(!closed_unmerged.is_satisfied());
795 assert_eq!(
796 closed_unmerged.unsatisfied_reason().as_deref(),
797 Some("closed, PR closed unmerged")
798 );
799 assert_eq!(open.unsatisfied_reason().as_deref(), Some("open"));
800 assert_eq!(approved_merge(true).unsatisfied_reason(), None);
801
802 let merged_unapproved = BlockerSnapshot::observe(
803 "closed",
804 Some("completed"),
805 Some(&ObservedPullRequest {
806 merged: true,
807 base_is_default: true,
808 approved: false,
809 closed_unmerged: false,
810 }),
811 );
812 assert!(merged_unapproved.is_satisfied());
813
814 assert!(BlockerSnapshot::observe("closed", None, None).is_satisfied());
815 assert!(BlockerSnapshot::observe("closed", Some("completed"), None).is_satisfied());
816 assert!(!BlockerSnapshot::observe("closed", Some("not_planned"), None).is_satisfied());
817 assert!(!BlockerSnapshot::observe("closed", Some("duplicate"), None).is_satisfied());
818 }
819
820 #[tokio::test]
821 async fn blocked_and_duplicate_stay_unmet_and_in_progress_is_not_rewritten() {
822 let store = TaskStore::open_memory().await.unwrap();
823 let a = task(&store, "owner/repo", "1", "A").await;
824 let b = task(&store, "owner/repo", "2", "B").await;
825 store.update_status(a, TaskStatus::Blocked).await.unwrap();
826 store
827 .replace_dependencies(
828 b,
829 &[NewDependency {
830 repo: "owner/repo".to_string(),
831 number: 1,
832 source: DepSource::Cli,
833 }],
834 )
835 .await
836 .unwrap();
837 store
838 .update_status(b, TaskStatus::InProgress)
839 .await
840 .unwrap();
841 assert!(store.block_claim_if_waiting(b).await.unwrap().is_some());
842 assert_eq!(store.get(b).await.unwrap().status, TaskStatus::InProgress);
843
844 store
845 .save_blocker_snapshot(
846 "owner/repo",
847 1,
848 &BlockerSnapshot::observe("closed", Some("duplicate"), None),
849 )
850 .await
851 .unwrap();
852 store.update_status(a, TaskStatus::Done).await.unwrap();
853 assert!(store.block_claim_if_waiting(b).await.unwrap().is_some());
854 assert_eq!(store.get(b).await.unwrap().status, TaskStatus::InProgress);
855 }
856
857 #[tokio::test]
858 async fn concurrent_claims_both_report_waiting() {
859 let store = std::sync::Arc::new(TaskStore::open_memory().await.unwrap());
860 let b = task(&store, "owner/repo", "2", "B").await;
861 store
862 .replace_dependencies(
863 b,
864 &[NewDependency {
865 repo: "owner/repo".to_string(),
866 number: 1,
867 source: DepSource::Github,
868 }],
869 )
870 .await
871 .unwrap();
872 let left = std::sync::Arc::clone(&store);
873 let right = std::sync::Arc::clone(&store);
874 let (a, c) = tokio::join!(
875 left.block_claim_if_waiting(b),
876 right.block_claim_if_waiting(b)
877 );
878 assert!(a.unwrap().is_some());
879 assert!(c.unwrap().is_some());
880 assert_eq!(store.get(b).await.unwrap().status, TaskStatus::Waiting);
881 }
882
883 #[tokio::test]
884 async fn unblock_selection_skips_waiting_and_blocked_with_deps_stays_waiting() {
885 let store = TaskStore::open_memory().await.unwrap();
886 let a = task(&store, "owner/repo", "1", "A").await;
887 let b = task(&store, "owner/repo", "2", "B").await;
888 store.update_status(a, TaskStatus::Blocked).await.unwrap();
889 store
890 .replace_dependencies(
891 b,
892 &[NewDependency {
893 repo: "owner/repo".to_string(),
894 number: 1,
895 source: DepSource::Github,
896 }],
897 )
898 .await
899 .unwrap();
900 store.block_claim_if_waiting(b).await.unwrap();
901 let blocked = store
902 .list_by_status("owner/repo", TaskStatus::Blocked)
903 .await
904 .unwrap();
905 assert!(blocked.iter().all(|task| task.id != b));
906 assert_eq!(store.get(b).await.unwrap().status, TaskStatus::Waiting);
907 assert_eq!(
908 store.status_after_unblock(a).await.unwrap(),
909 TaskStatus::New
910 );
911 store.update_status(a, TaskStatus::Blocked).await.unwrap();
912 store
913 .replace_dependencies(
914 a,
915 &[NewDependency {
916 repo: "owner/repo".to_string(),
917 number: 9,
918 source: DepSource::Cli,
919 }],
920 )
921 .await
922 .unwrap();
923 assert_eq!(
924 store.status_after_unblock(a).await.unwrap(),
925 TaskStatus::Waiting
926 );
927 }
928
929 #[tokio::test]
930 async fn entering_waiting_clears_a_stored_branch() {
931 let store = TaskStore::open_memory().await.unwrap();
932 let b = task(&store, "owner/repo", "2", "B").await;
933 store
934 .set_fields(
935 b,
936 &[
937 ("branch", serde_json::json!("old-branch")),
938 ("worktree", serde_json::json!("/tmp/old")),
939 ],
940 )
941 .await
942 .unwrap();
943 store
944 .replace_dependencies(
945 b,
946 &[NewDependency {
947 repo: "owner/repo".to_string(),
948 number: 1,
949 source: DepSource::Github,
950 }],
951 )
952 .await
953 .unwrap();
954 store.block_claim_if_waiting(b).await.unwrap();
955 let task = store.get(b).await.unwrap();
956 assert_eq!(task.status, TaskStatus::Waiting);
957 assert_eq!(task.branch, "");
958 assert_eq!(task.worktree, "");
959 }
960
961 #[test]
962 fn blocked_external_from_body_reads_the_note_and_the_marker() {
963 let body = concat!(
964 "blocked-external: M1-05, M1-01 (no issue yet)\n\n",
965 "<!-- orch-blocked-external: M1-05, M1-01 -->\n",
966 "<!-- orch-plan: M6-01 -->\n",
967 );
968 assert_eq!(
969 blocked_external_from_body(body),
970 vec!["M1-05".to_string(), "M1-01".to_string()]
971 );
972 let blocked = blocked_external_from_body("<!-- orch-plan: M6-01 -->");
973 assert!(blocked.is_empty(), "{blocked:?}");
974 }
975
976 #[tokio::test]
977 async fn blocked_external_note_holds_the_task_and_clears_when_removed() {
978 let store = TaskStore::open_memory().await.unwrap();
979 let body = concat!(
980 "blocked-external: M3-01 (no issue yet)\n\n",
981 "<!-- orch-blocked-external: M3-01 -->\n",
982 );
983 let b = task_with_body(&store, "owner/repo", "4", "M4-01 API", body).await;
984 let held = store.block_claim_if_waiting(b).await.unwrap();
985 assert_eq!(held, Some(vec!["M3-01".to_string()]));
986 assert_eq!(store.get(b).await.unwrap().status, TaskStatus::Waiting);
987
988 store
989 .upsert_external(&crate::store::UpsertExternal {
990 repo: "owner/repo",
991 ext_id: "4",
992 title: "M4-01 API",
993 body: "<!-- orch-plan: M4-01 -->\n",
994 author: "",
995 url: "",
996 labels: &[],
997 origin: "github",
998 })
999 .await
1000 .unwrap();
1001 let released = store.block_claim_if_waiting(b).await.unwrap();
1002 assert_eq!(released, None);
1003 assert_eq!(store.get(b).await.unwrap().status, TaskStatus::New);
1004 }
1005
1006 #[tokio::test]
1007 async fn migration_028_applies_over_existing_tasks() {
1008 let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("migrations");
1009 let options = sqlx::sqlite::SqliteConnectOptions::new()
1010 .filename(":memory:")
1011 .pragma("foreign_keys", "ON");
1012 let pool = sqlx::sqlite::SqlitePoolOptions::new()
1013 .max_connections(1)
1014 .connect_with(options)
1015 .await
1016 .unwrap();
1017 let mut files: Vec<_> = std::fs::read_dir(&dir)
1018 .unwrap()
1019 .map(|entry| entry.unwrap().path())
1020 .filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("sql"))
1021 .collect();
1022 files.sort();
1023 for path in &files {
1024 let name = path.file_name().unwrap().to_string_lossy();
1025 if name.starts_with("028") {
1026 continue;
1027 }
1028 let sql = std::fs::read_to_string(path).unwrap();
1029 sqlx::raw_sql(sqlx::AssertSqlSafe(sql.as_str()))
1030 .execute(&pool)
1031 .await
1032 .unwrap_or_else(|err| panic!("migration {name} failed: {err}"));
1033 }
1034 sqlx::query("INSERT INTO tasks (repo, title) VALUES ('owner/repo', 'existing')")
1035 .execute(&pool)
1036 .await
1037 .unwrap();
1038 let sql = std::fs::read_to_string(dir.join("028_task_dependencies.sql")).unwrap();
1039 sqlx::raw_sql(sqlx::AssertSqlSafe(sql.as_str()))
1040 .execute(&pool)
1041 .await
1042 .unwrap();
1043 let (id, title): (i64, String) = sqlx::query_as("SELECT id, title FROM tasks")
1044 .fetch_one(&pool)
1045 .await
1046 .unwrap();
1047 assert_eq!(title, "existing");
1048 sqlx::query(
1049 "INSERT INTO task_dependencies (task_id, depends_on_repo, depends_on_number, source)
1050 VALUES (?, 'owner/repo', 1, 'cli')",
1051 )
1052 .bind(id)
1053 .execute(&pool)
1054 .await
1055 .unwrap();
1056 let (count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM task_dependencies")
1057 .fetch_one(&pool)
1058 .await
1059 .unwrap();
1060 assert_eq!(count, 1);
1061 }
1062}
1063
1064pub(crate) mod apply {
1066 use super::{
1067 Context, NewDependency, Row, SqliteConnection, blocked_external_from_body, load_blockers,
1068 worktree_refresh_key,
1069 };
1070
1071 pub(crate) async fn move_repo(
1072 conn: &mut SqliteConnection,
1073 old: &str,
1074 new: &str,
1075 ) -> anyhow::Result<u64> {
1076 let existing: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM tasks WHERE repo = ?")
1077 .bind(new)
1078 .fetch_one(&mut *conn)
1079 .await?;
1080 if existing > 0 {
1081 anyhow::bail!("{new} already has {existing} task(s), refusing to move {old} onto it");
1082 }
1083 let moved = sqlx::query("UPDATE tasks SET repo = ? WHERE repo = ?")
1084 .bind(new)
1085 .bind(old)
1086 .execute(&mut *conn)
1087 .await?
1088 .rows_affected();
1089 for sql in [
1090 "UPDATE task_dependencies SET depends_on_repo = ? WHERE depends_on_repo = ?",
1091 "UPDATE task_metrics SET repo = ? WHERE repo = ?",
1092 "UPDATE job_state SET repo = ? WHERE repo = ?",
1093 "UPDATE channel_subscriptions SET repo = ? WHERE repo = ?",
1094 ] {
1095 sqlx::query(sql)
1096 .bind(new)
1097 .bind(old)
1098 .execute(&mut *conn)
1099 .await?;
1100 }
1101 move_repo_kv(&mut *conn, old, new).await?;
1102 Ok(moved)
1103 }
1104
1105 async fn move_repo_kv(conn: &mut SqliteConnection, old: &str, new: &str) -> anyhow::Result<()> {
1113 for prefix in [
1114 format!("dep_blocker:{old}#"),
1115 format!("dep_blocker_read_at:{old}#"),
1116 ] {
1117 sqlx::query("DELETE FROM kv WHERE substr(key, 1, ?) = ?")
1118 .bind(prefix_len(&prefix)?)
1119 .bind(&prefix)
1120 .execute(&mut *conn)
1121 .await?;
1122 }
1123 sqlx::query("DELETE FROM kv WHERE key = ?")
1124 .bind(format!("review_batch_fail:{old}"))
1125 .execute(&mut *conn)
1126 .await?;
1127 for (from, to) in [
1128 (
1129 format!("dep_cycle_comment:{old}#"),
1130 format!("dep_cycle_comment:{new}#"),
1131 ),
1132 (
1133 format!("review_approved_sha:{old}#"),
1134 format!("review_approved_sha:{new}#"),
1135 ),
1136 (format!("ci_check_ts:{old}#"), format!("ci_check_ts:{new}#")),
1137 (
1138 format!("ci_pending_since:{old}#"),
1139 format!("ci_pending_since:{new}#"),
1140 ),
1141 ] {
1142 sqlx::query(
1143 "UPDATE OR REPLACE kv SET key = ? || substr(key, ?) WHERE substr(key, 1, ?) = ?",
1144 )
1145 .bind(&to)
1146 .bind(prefix_len(&from)? + 1)
1147 .bind(prefix_len(&from)?)
1148 .bind(&from)
1149 .execute(&mut *conn)
1150 .await?;
1151 }
1152 sqlx::query(
1154 "UPDATE OR REPLACE kv SET key = replace(key, ?, ?)
1155 WHERE key GLOB 'dep_blocker_notice:*' AND instr(key, ?) > 0",
1156 )
1157 .bind(format!(":{old}#"))
1158 .bind(format!(":{new}#"))
1159 .bind(format!(":{old}#"))
1160 .execute(&mut *conn)
1161 .await?;
1162 sqlx::query("UPDATE OR REPLACE kv SET key = ? WHERE key = ?")
1163 .bind(format!("issues_last_ingested:{new}"))
1164 .bind(format!("issues_last_ingested:{old}"))
1165 .execute(&mut *conn)
1166 .await?;
1167 Ok(())
1168 }
1169
1170 fn prefix_len(prefix: &str) -> anyhow::Result<i64> {
1172 Ok(i64::try_from(prefix.chars().count())?)
1173 }
1174
1175 pub(crate) async fn replace_dependencies(
1176 conn: &mut SqliteConnection,
1177 task_id: i64,
1178 rows: &[NewDependency],
1179 ) -> anyhow::Result<()> {
1180 sqlx::query("DELETE FROM task_dependencies WHERE task_id = ?")
1181 .bind(task_id)
1182 .execute(&mut *conn)
1183 .await?;
1184 for row in rows {
1185 if row.number <= 0 || row.repo.is_empty() {
1186 continue;
1187 }
1188 sqlx::query(
1189 "INSERT INTO task_dependencies
1190 (task_id, depends_on_repo, depends_on_number, source)
1191 VALUES (?, ?, ?, ?)",
1192 )
1193 .bind(task_id)
1194 .bind(&row.repo)
1195 .bind(row.number)
1196 .bind(row.source.as_str())
1197 .execute(&mut *conn)
1198 .await?;
1199 }
1200 sqlx::query(
1201 "UPDATE task_dependencies
1202 SET depends_on_task_id = (
1203 SELECT id FROM tasks
1204 WHERE tasks.repo = task_dependencies.depends_on_repo
1205 AND tasks.external_id = CAST(task_dependencies.depends_on_number AS TEXT)
1206 LIMIT 1
1207 )
1208 WHERE task_id = ?",
1209 )
1210 .bind(task_id)
1211 .execute(&mut *conn)
1212 .await?;
1213 Ok(())
1214 }
1215
1216 pub(crate) async fn link_known_task(
1217 conn: &mut SqliteConnection,
1218 task_id: i64,
1219 repo: &str,
1220 number: i64,
1221 ) -> anyhow::Result<()> {
1222 sqlx::query(
1223 "UPDATE task_dependencies
1224 SET depends_on_task_id = ?
1225 WHERE depends_on_repo = ? AND depends_on_number = ?",
1226 )
1227 .bind(task_id)
1228 .bind(repo)
1229 .bind(number)
1230 .execute(conn)
1231 .await?;
1232 Ok(())
1233 }
1234
1235 pub(crate) async fn block_claim(
1236 conn: &mut SqliteConnection,
1237 now: &str,
1238 task_id: i64,
1239 ) -> anyhow::Result<Option<Vec<String>>> {
1240 sqlx::query("UPDATE tasks SET updated_at = updated_at WHERE id = ?")
1243 .bind(task_id)
1244 .execute(&mut *conn)
1245 .await
1246 .context("lock task for dependency claim")?;
1247 let row = sqlx::query("SELECT status, agent, model, repo, body FROM tasks WHERE id = ?")
1248 .bind(task_id)
1249 .fetch_optional(&mut *conn)
1250 .await?;
1251 let Some(row) = row else {
1252 return Ok(None);
1253 };
1254 let status: String = row.try_get("status")?;
1255 let agent: Option<String> = row.try_get("agent")?;
1256 let model: Option<String> = row.try_get("model")?;
1257 let repo: String = row.try_get("repo")?;
1258 let body: String = row.try_get("body").unwrap_or_default();
1259 let views = load_blockers(conn, task_id, &repo).await?;
1260 let mut unmet: Vec<String> = views
1261 .iter()
1262 .filter(|view| !view.satisfied)
1263 .map(|view| view.label.clone())
1264 .collect();
1265 for id in blocked_external_from_body(&body) {
1266 if !unmet.iter().any(|have| have == &id) {
1267 unmet.push(id);
1268 }
1269 }
1270
1271 let holdable = matches!(
1272 status.as_str(),
1273 "new" | "routed" | "waiting" | "blocked" | "needs_review"
1274 );
1275 if unmet.is_empty() {
1276 if status == "waiting" {
1277 sqlx::query(
1278 "UPDATE tasks
1279 SET status = 'new', block_reason = NULL, branch = '', worktree = '',
1280 updated_at = ?
1281 WHERE id = ?",
1282 )
1283 .bind(now)
1284 .bind(task_id)
1285 .execute(&mut *conn)
1286 .await?;
1287 sqlx::query(
1288 "INSERT INTO kv (key, value, updated_at)
1289 VALUES (?, '1', ?)
1290 ON CONFLICT(key) DO UPDATE
1291 SET value = excluded.value, updated_at = excluded.updated_at",
1292 )
1293 .bind(worktree_refresh_key(task_id))
1294 .bind(now)
1295 .execute(&mut *conn)
1296 .await?;
1297 crate::store::tasks::apply::activity(
1298 conn,
1299 task_id,
1300 "status_change",
1301 Some("waiting"),
1302 Some("new"),
1303 agent.as_deref(),
1304 model.as_deref(),
1305 None,
1306 )
1307 .await?;
1308 }
1309 return Ok(None);
1310 }
1311
1312 if holdable && status != "waiting" {
1313 sqlx::query(
1314 "UPDATE tasks
1315 SET status = 'waiting', branch = '', worktree = '', block_reason = NULL,
1316 updated_at = ?
1317 WHERE id = ?",
1318 )
1319 .bind(now)
1320 .bind(task_id)
1321 .execute(&mut *conn)
1322 .await?;
1323 crate::store::tasks::apply::activity(
1324 conn,
1325 task_id,
1326 "status_change",
1327 Some(&status),
1328 Some("waiting"),
1329 agent.as_deref(),
1330 model.as_deref(),
1331 None,
1332 )
1333 .await?;
1334 }
1335 Ok(Some(unmet))
1336 }
1337}