1use crate::backends::github::GitHubBackend;
2use crate::backends::{ExternalBackend, ExternalId};
3use crate::config;
4use crate::engine::cleanup::{
5 cleanup_orphaned_worktree, remove_worktree_and_branch, resolve_repo_root,
6};
7use crate::github::http::GhHttp;
8use crate::store::{Task, TaskStatus, TaskStore};
9use crate::tmux::TmuxManager;
10use anyhow::Context;
11use std::io::Write;
12use std::path::Path;
13use std::sync::atomic::{AtomicBool, Ordering};
14use std::sync::Arc;
15
16enum FixAction {
18 CommitPushCreatePr { store_id: i64 },
20 PushCreatePr { store_id: i64 },
22 CreatePr { store_id: i64 },
24 LinkPrAndReopen { store_id: i64, pr_number: u64 },
26 LinkPr { store_id: i64, pr_number: u64 },
28 ReopenIssue { store_id: i64 },
30 CloseIssue { store_id: i64 },
32 SyncLabels { store_id: i64 },
34 ResetToRouted { store_id: i64 },
36 CleanWorktree { store_id: i64 },
38 RemoveOrphanedWorktree { path: String },
40 MarkDoneFromMergedPr { store_id: i64 },
42 ResetDeadReview { store_id: i64 },
44 None,
46}
47
48struct Finding {
50 severity: Severity,
51 task_id: String,
52 message: String,
53 fix: FixAction,
54}
55
56#[derive(Clone, Copy)]
57enum Severity {
58 Error,
59 Warning,
60}
61
62impl Severity {
63 fn symbol(self) -> &'static str {
64 match self {
65 Self::Error => "ERR",
66 Self::Warning => "WARN",
67 }
68 }
69}
70
71pub async fn run(full: bool, fix: bool, dry_run: bool) -> anyhow::Result<()> {
76 let store = Arc::new(crate::cli::init_store().await?);
77 let gh = GhHttp::new()?;
78 let tmux = TmuxManager::new();
79
80 let repos: Vec<String> = match config::get_current_repo() {
82 Ok(repo) => vec![repo],
83 Err(_) => {
84 let all = store.distinct_repos().await?;
85 if all.is_empty() {
86 println!("No tasks found in store.");
87 return Ok(());
88 }
89 all
90 }
91 };
92
93 let spinner_running = Arc::new(AtomicBool::new(true));
95 let spinner_flag = spinner_running.clone();
96 let spinner_task = tokio::spawn(async move {
97 let frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
98 let mut i = 0usize;
99 while spinner_flag.load(Ordering::Relaxed) {
100 eprint!(
101 "\r{} Running orch service doctor…",
102 frames[i % frames.len()]
103 );
104 let _ = std::io::stderr().flush();
105 tokio::time::sleep(std::time::Duration::from_millis(80)).await;
106 i += 1;
107 }
108 eprint!("\r");
109 let _ = std::io::stderr().flush();
110 });
111
112 let result = run_checks(full, fix, dry_run, &repos, &store, &gh, &tmux).await;
113
114 spinner_running.store(false, Ordering::Relaxed);
115 let _ = spinner_task.await;
116
117 result
118}
119
120async fn run_checks(
121 full: bool,
122 fix: bool,
123 dry_run: bool,
124 repos: &[String],
125 store: &Arc<TaskStore>,
126 gh: &GhHttp,
127 tmux: &TmuxManager,
128) -> anyhow::Result<()> {
129 let mut all_findings: Vec<Finding> = Vec::new();
130
131 for repo in repos {
132 let backend: Arc<dyn ExternalBackend> = Arc::new(GitHubBackend::new(repo.clone())?);
133 let mut findings: Vec<Finding> = Vec::new();
134 if full {
135 let cutoff = chrono::Utc::now() - chrono::Duration::days(7);
139 let cutoff_str = cutoff.format("%Y-%m-%dT%H:%M:%SZ").to_string();
140 let all_tasks = store.list_for_doctor(repo, &cutoff_str).await?;
141
142 let recent_done_tasks: Vec<_> = all_tasks
143 .iter()
144 .filter(|t| t.status == TaskStatus::Done)
145 .collect();
146 let active_tasks: Vec<_> = all_tasks
147 .iter()
148 .filter(|t| t.status != TaskStatus::Done)
149 .collect();
150 let in_progress_tasks: Vec<_> = all_tasks
151 .iter()
152 .filter(|t| t.status == TaskStatus::InProgress)
153 .collect();
154 let in_review_tasks: Vec<_> = all_tasks
155 .iter()
156 .filter(|t| t.status == TaskStatus::InReview)
157 .collect();
158
159 let pr_numbers_to_fetch: Vec<u64> = {
161 let mut nums: Vec<u64> = recent_done_tasks
162 .iter()
163 .filter(|t| !(t.origin == "internal" && t.external_id.is_none()))
164 .filter_map(|t| t.pr_number.map(|n| n as u64))
165 .chain(
166 active_tasks
167 .iter()
168 .filter_map(|t| t.pr_number.map(|n| n as u64)),
169 )
170 .collect();
171 nums.sort_unstable();
172 nums.dedup();
173 nums
174 };
175 let pr_states = gh
176 .batch_get_pr_states(repo, &pr_numbers_to_fetch)
177 .await
178 .unwrap_or_default();
179
180 for task in &recent_done_tasks {
182 if task.origin == "internal" && task.external_id.is_none() {
183 continue;
184 }
185 if task.pr_number.is_none() && task.external_id.is_none() {
186 continue;
187 }
188 check_done_no_merged_pr(task, gh, &pr_states, &mut findings, repo).await;
189 }
190
191 for task in &recent_done_tasks {
193 check_dirty_worktree(task, &mut findings).await;
194 }
195
196 for task in &recent_done_tasks {
198 check_unpushed_commits(task, &mut findings).await;
199 }
200
201 let external_active: Vec<_> = active_tasks
204 .iter()
205 .copied()
206 .filter(|t| t.origin != "internal")
207 .collect();
208 let issue_numbers = collect_valid_external_issue_numbers(
209 external_active.iter().copied(),
210 repo,
211 "cli::doctor::run_checks(full)",
212 );
213 let issue_states = gh
214 .batch_get_issue_states(repo, &issue_numbers, "cli::doctor::run_checks(full)")
215 .await
216 .unwrap_or_default();
217 for task in &external_active {
218 check_issue_status_mismatch(task, &issue_states, &mut findings);
219 check_label_mismatch(task, &issue_states, &mut findings);
220 }
221
222 for task in &in_progress_tasks {
224 check_stale_in_progress(task, repo, tmux, &mut findings).await;
225 }
226
227 check_orphaned_worktrees(&all_tasks, &mut findings).await;
229
230 for task in &active_tasks {
232 check_pr_merged_not_done(task, &pr_states, &mut findings);
233 }
234
235 for task in &in_review_tasks {
237 check_dead_review_session(task, repo, tmux, &mut findings).await;
238 }
239 } else {
240 let active_tasks = store.list_active(repo).await?;
242
243 let open_prs: Vec<serde_json::Value> =
245 gh.list_open_pulls(repo).await.unwrap_or_default();
246
247 let mut pr_by_number: std::collections::HashMap<u64, serde_json::Value> =
249 std::collections::HashMap::new();
250 let mut pr_by_head: std::collections::HashMap<String, serde_json::Value> =
251 std::collections::HashMap::new();
252 for pr in &open_prs {
253 if let Some(n) = pr.get("number").and_then(|v| v.as_u64()) {
254 pr_by_number.insert(n, pr.clone());
255 }
256 if let Some(head) = pr.pointer("/head/ref").and_then(|v| v.as_str()) {
257 pr_by_head.insert(head.to_string(), pr.clone());
258 }
259 }
260
261 let external_active: Vec<_> = active_tasks
263 .iter()
264 .filter(|t| t.origin != "internal")
265 .collect();
266 for task in &active_tasks {
267 if task.status == TaskStatus::InProgress {
269 check_stale_in_progress(task, repo, tmux, &mut findings).await;
270 }
271
272 if task.status == TaskStatus::InReview {
274 check_dead_review_session(task, repo, tmux, &mut findings).await;
275 }
276
277 if let Some(pr_num) = task.pr_number {
279 match gh.get_pr(repo, pr_num as u64).await {
280 Ok(pr) => {
281 if pr.merged.unwrap_or(false) {
282 findings.push(Finding {
283 severity: Severity::Error,
284 task_id: task_label(task),
285 message: format!(
286 "PR #{} is merged but task status is '{}'",
287 pr_num,
288 task.status.as_str()
289 ),
290 fix: FixAction::MarkDoneFromMergedPr { store_id: task.id },
291 });
292 }
293 }
294 Err(_) => {
295 }
297 }
298 } else if !task.branch.is_empty() {
299 if let Some(pr) = pr_by_head.get(&task.branch) {
301 if task.pr_number.is_none() {
303 if let Some(n) = pr.get("number").and_then(|v| v.as_u64()) {
304 findings.push(Finding {
305 severity: Severity::Warning,
306 task_id: task_label(task),
307 message: format!(
308 "branch '{}' has open PR #{} not linked in store",
309 task.branch, n
310 ),
311 fix: FixAction::LinkPr {
312 store_id: task.id,
313 pr_number: n,
314 },
315 });
316 }
317 }
318 }
319 }
320 }
321
322 let external_issue_numbers = collect_valid_external_issue_numbers(
324 external_active.iter().copied(),
325 repo,
326 "cli::doctor::run_checks(fast)",
327 );
328 let issue_states = gh
329 .batch_get_issue_states(
330 repo,
331 &external_issue_numbers,
332 "cli::doctor::run_checks(fast)",
333 )
334 .await
335 .unwrap_or_default();
336 for task in &external_active {
337 check_issue_status_mismatch(task, &issue_states, &mut findings);
338 check_label_mismatch(task, &issue_states, &mut findings);
339 }
340
341 for pr in &open_prs {
343 let pr_num = pr.get("number").and_then(|v| v.as_u64());
344 let head = pr
345 .pointer("/head/ref")
346 .and_then(|v| v.as_str())
347 .map(|s| s.to_string());
348 let mut owned = false;
349 if let Some(n) = pr_num {
350 for t in &active_tasks {
351 if let Some(pn) = t.pr_number {
352 if pn as u64 == n {
353 owned = true;
354 break;
355 }
356 }
357 if !t.branch.is_empty() && Some(t.branch.clone()) == head {
358 owned = true;
359 break;
360 }
361 }
362 }
363 if !owned {
364 let num_str = pr_num
365 .map(|n| n.to_string())
366 .unwrap_or_else(|| "?".to_string());
367 findings.push(Finding {
368 severity: Severity::Warning,
369 task_id: format!("pr:{}", num_str),
370 message: format!("open PR with no active task: #{}", num_str),
371 fix: FixAction::None,
372 });
373 }
374 }
375
376 check_orphaned_worktrees(&active_tasks, &mut findings).await;
378 }
379
380 if repos.len() > 1 && !findings.is_empty() {
381 println!("\n=== {} ===", repo);
382 }
383
384 if (fix || dry_run) && !findings.is_empty() {
386 let errors = findings
387 .iter()
388 .filter(|f| matches!(f.severity, Severity::Error))
389 .count();
390 let warnings = findings.len() - errors;
391 for f in &findings {
392 println!("[{}] #{}: {}", f.severity.symbol(), f.task_id, f.message);
393 }
394 println!(
395 "\nFound {} issue(s): {} error(s), {} warning(s)",
396 findings.len(),
397 errors,
398 warnings,
399 );
400 apply_fixes(&findings, store, repo, &backend, gh, dry_run).await?;
401 }
402
403 all_findings.extend(findings);
404 }
405
406 if all_findings.is_empty() {
407 println!("No issues found. All task state is consistent.");
408 return Ok(());
409 }
410
411 if !fix && !dry_run {
413 let errors = all_findings
414 .iter()
415 .filter(|f| matches!(f.severity, Severity::Error))
416 .count();
417 let warnings = all_findings.len() - errors;
418
419 for f in &all_findings {
420 println!("[{}] #{}: {}", f.severity.symbol(), f.task_id, f.message);
421 }
422
423 println!(
424 "\nFound {} issue(s): {} error(s), {} warning(s)",
425 all_findings.len(),
426 errors,
427 warnings,
428 );
429
430 if errors > 0 {
431 println!("\nRun `orch service doctor --fix` to attempt automatic repairs.");
432 println!("Run `orch service doctor --dry-run` to preview what --fix would do.");
433 }
434 }
435
436 Ok(())
437}
438
439async fn check_done_no_merged_pr(
442 task: &Task,
443 gh: &GhHttp,
444 pr_states: &std::collections::HashMap<u64, (bool, String)>,
445 findings: &mut Vec<Finding>,
446 repo: &str,
447) {
448 let task_label = task_label(task);
449 let store_id = task.id;
450
451 match task.pr_number {
452 Some(pr_num) => {
453 let (merged, state) = if let Some(s) = pr_states.get(&(pr_num as u64)) {
455 s.clone()
456 } else {
457 match gh.get_pr(repo, pr_num as u64).await {
458 Ok(pr) => (pr.merged.unwrap_or(false), pr.state),
459 Err(e) => {
460 findings.push(Finding {
461 severity: Severity::Warning,
462 task_id: task_label,
463 message: format!(
464 "done with PR #{} but failed to fetch PR: {}",
465 pr_num, e
466 ),
467 fix: FixAction::None,
468 });
469 return;
470 }
471 }
472 };
473 if !merged {
474 findings.push(Finding {
475 severity: Severity::Error,
476 task_id: task_label,
477 message: format!("done but PR #{} is {} (not merged)", pr_num, state),
478 fix: FixAction::LinkPrAndReopen {
479 store_id,
480 pr_number: pr_num as u64,
481 },
482 });
483 }
484 }
485 None => {
486 if !task.branch.is_empty() {
488 match gh.get_pr_number(repo, &task.branch).await {
489 Ok(Some(pr_num)) => {
490 let merged = gh
492 .get_pr(repo, pr_num)
493 .await
494 .map(|pr| pr.merged == Some(true))
495 .unwrap_or(false);
496 if merged {
497 findings.push(Finding {
499 severity: Severity::Warning,
500 task_id: task_label,
501 message: format!(
502 "done with no pr_number but branch has merged PR #{}",
503 pr_num
504 ),
505 fix: FixAction::LinkPr {
506 store_id,
507 pr_number: pr_num,
508 },
509 });
510 } else {
511 findings.push(Finding {
513 severity: Severity::Error,
514 task_id: task_label,
515 message: format!(
516 "done with no pr_number but branch has unmerged PR #{}",
517 pr_num
518 ),
519 fix: FixAction::LinkPrAndReopen {
520 store_id,
521 pr_number: pr_num,
522 },
523 });
524 }
525 }
526 Ok(None) => {
527 let fix = classify_no_pr_fix(task, store_id).await;
529 findings.push(Finding {
530 severity: Severity::Error,
531 task_id: task_label,
532 message: "done but no PR was ever created".to_string(),
533 fix,
534 });
535 }
536 Err(_) => {
537 findings.push(Finding {
538 severity: Severity::Error,
539 task_id: task_label,
540 message: "done but no PR number recorded and branch lookup failed"
541 .to_string(),
542 fix: FixAction::None,
543 });
544 }
545 }
546 } else {
547 findings.push(Finding {
548 severity: Severity::Error,
549 task_id: task_label,
550 message: "done but no branch or PR recorded".to_string(),
551 fix: FixAction::None,
552 });
553 }
554 }
555 }
556}
557
558async fn classify_no_pr_fix(task: &Task, store_id: i64) -> FixAction {
560 if task.worktree.is_empty() || task.worktree_cleaned {
561 return FixAction::None;
563 }
564 let wt = Path::new(&task.worktree);
565 if !wt.exists() || !wt.join(".git").exists() {
566 return FixAction::None;
567 }
568
569 let has_dirty = tokio::process::Command::new("git")
571 .args(["status", "--porcelain"])
572 .current_dir(wt)
573 .output()
574 .await
575 .map(|o| !o.stdout.is_empty())
576 .unwrap_or(false);
577
578 if has_dirty {
579 return FixAction::CommitPushCreatePr { store_id };
580 }
581
582 let has_unpushed = tokio::process::Command::new("git")
584 .args(["log", &format!("origin/{}..HEAD", task.branch), "--oneline"])
585 .current_dir(wt)
586 .output()
587 .await
588 .map(|o| !o.stdout.is_empty())
589 .unwrap_or(false);
590
591 if has_unpushed {
592 return FixAction::PushCreatePr { store_id };
593 }
594
595 let branch_on_remote = tokio::process::Command::new("git")
597 .args(["ls-remote", "--heads", "origin", &task.branch])
598 .current_dir(wt)
599 .output()
600 .await
601 .map(|o| !o.stdout.is_empty())
602 .unwrap_or(false);
603
604 if branch_on_remote {
605 return FixAction::CreatePr { store_id };
606 }
607
608 FixAction::None
609}
610
611async fn check_dirty_worktree(task: &Task, findings: &mut Vec<Finding>) {
613 if task.worktree.is_empty() || task.worktree_cleaned {
614 return;
615 }
616 let wt = Path::new(&task.worktree);
617 if !wt.exists() || !wt.join(".git").exists() {
618 return;
619 }
620 let output = tokio::process::Command::new("git")
621 .args(["status", "--porcelain"])
622 .current_dir(wt)
623 .output()
624 .await;
625 if let Ok(out) = output {
626 if !out.stdout.is_empty() {
627 findings.push(Finding {
628 severity: Severity::Error,
629 task_id: task_label(task),
630 message: format!(
631 "done but worktree has uncommitted changes: {}",
632 task.worktree
633 ),
634 fix: FixAction::CommitPushCreatePr { store_id: task.id },
635 });
636 }
637 }
638}
639
640async fn check_unpushed_commits(task: &Task, findings: &mut Vec<Finding>) {
642 if task.worktree.is_empty() || task.worktree_cleaned || task.branch.is_empty() {
643 return;
644 }
645 let wt = Path::new(&task.worktree);
646 if !wt.exists() || !wt.join(".git").exists() {
647 return;
648 }
649 let output = tokio::process::Command::new("git")
650 .args(["log", &format!("origin/{}..HEAD", task.branch), "--oneline"])
651 .current_dir(wt)
652 .output()
653 .await;
654 if let Ok(out) = output {
655 if !out.stdout.is_empty() {
656 let count = out.stdout.iter().filter(|&&b| b == b'\n').count();
657 findings.push(Finding {
658 severity: Severity::Error,
659 task_id: task_label(task),
660 message: format!(
661 "done but {} unpushed commit(s) in worktree: {}",
662 count, task.worktree
663 ),
664 fix: FixAction::PushCreatePr { store_id: task.id },
665 });
666 }
667 }
668}
669
670fn check_issue_status_mismatch(
672 task: &Task,
673 issue_states: &std::collections::HashMap<String, (String, Vec<String>)>,
674 findings: &mut Vec<Finding>,
675) {
676 let ext_id = match &task.external_id {
677 Some(id) => id.clone(),
678 None => return,
679 };
680
681 let (state, _labels) = match issue_states.get(&ext_id) {
682 Some(v) => v,
683 None => return,
684 };
685
686 let issue_closed = state == "closed";
687 let task_done = task.status == TaskStatus::Done;
688 let task_blocked = task.status == TaskStatus::Blocked;
689
690 if issue_closed && !task_done && !task_blocked {
691 findings.push(Finding {
692 severity: Severity::Error,
693 task_id: task_label(task),
694 message: format!(
695 "GitHub issue is closed but SQLite status is '{}'",
696 task.status.as_str()
697 ),
698 fix: FixAction::ReopenIssue { store_id: task.id },
699 });
700 } else if !issue_closed && task_done {
701 findings.push(Finding {
702 severity: Severity::Warning,
703 task_id: task_label(task),
704 message: "SQLite status is 'done' but GitHub issue is still open".to_string(),
705 fix: FixAction::CloseIssue { store_id: task.id },
706 });
707 }
708}
709
710fn check_label_mismatch(
712 task: &Task,
713 issue_states: &std::collections::HashMap<String, (String, Vec<String>)>,
714 findings: &mut Vec<Finding>,
715) {
716 let ext_id = match &task.external_id {
717 Some(id) => id.clone(),
718 None => return,
719 };
720
721 let (_state, labels) = match issue_states.get(&ext_id) {
722 Some(v) => v,
723 None => return,
724 };
725
726 let expected_label = format!("status:{}", task.status.as_str());
727 let has_expected = labels.iter().any(|l| l == &expected_label);
728 let status_labels: Vec<_> = labels.iter().filter(|l| l.starts_with("status:")).collect();
729
730 if !has_expected {
731 let actual = if status_labels.is_empty() {
732 "none".to_string()
733 } else {
734 status_labels
735 .iter()
736 .map(|s| s.as_str())
737 .collect::<Vec<_>>()
738 .join(", ")
739 };
740 findings.push(Finding {
741 severity: Severity::Warning,
742 task_id: task_label(task),
743 message: format!(
744 "label mismatch: SQLite='{}' but GitHub labels=[{}]",
745 task.status.as_str(),
746 actual
747 ),
748 fix: FixAction::SyncLabels { store_id: task.id },
749 });
750 }
751}
752
753async fn check_stale_in_progress(
755 task: &Task,
756 repo: &str,
757 tmux: &TmuxManager,
758 findings: &mut Vec<Finding>,
759) {
760 let task_id_str = match &task.external_id {
761 Some(id) => id.clone(),
762 None => format!("internal:{}", task.id),
763 };
764 let session = tmux.session_name(repo, &task_id_str);
765 if !tmux.session_exists(&session).await {
766 findings.push(Finding {
767 severity: Severity::Error,
768 task_id: task_label(task),
769 message: format!(
770 "status is 'in_progress' but no tmux session '{}' found",
771 session
772 ),
773 fix: FixAction::ResetToRouted { store_id: task.id },
774 });
775 }
776}
777
778async fn check_orphaned_worktrees(all_tasks: &[Task], findings: &mut Vec<Finding>) {
780 let worktrees_dir = match crate::home::worktrees_dir() {
781 Ok(d) => d,
782 Err(_) => return,
783 };
784
785 let project_dirs = match std::fs::read_dir(&worktrees_dir) {
786 Ok(d) => d,
787 Err(_) => return,
788 };
789
790 let task_by_worktree: std::collections::HashMap<&str, &Task> = all_tasks
792 .iter()
793 .filter(|t| !t.worktree.is_empty())
794 .map(|t| (t.worktree.as_str(), t))
795 .collect();
796
797 for project_entry in project_dirs {
798 let project_entry = match project_entry {
799 Ok(e) => e,
800 Err(e) => {
801 tracing::warn!(err = %e, "error reading worktrees directory entry");
802 continue;
803 }
804 };
805 if !project_entry
806 .file_type()
807 .map(|t| t.is_dir())
808 .unwrap_or(false)
809 {
810 continue;
811 }
812 let branch_dirs = match std::fs::read_dir(project_entry.path()) {
813 Ok(d) => d,
814 Err(_) => continue,
815 };
816 for branch_entry in branch_dirs {
817 let branch_entry = match branch_entry {
818 Ok(e) => e,
819 Err(e) => {
820 tracing::warn!(err = %e, "error reading project directory entry");
821 continue;
822 }
823 };
824 if !branch_entry
825 .file_type()
826 .map(|t| t.is_dir())
827 .unwrap_or(false)
828 {
829 continue;
830 }
831 let wt_path = branch_entry.path().to_string_lossy().to_string();
832
833 let owning_task = task_by_worktree.get(wt_path.as_str()).copied();
834
835 match owning_task {
836 Some(task) => {
837 if task.status == TaskStatus::Done && !task.worktree_cleaned {
838 let has_work = worktree_has_work(Path::new(&wt_path), &task.branch).await;
840 if has_work {
841 findings.push(Finding {
843 severity: Severity::Warning,
844 task_id: task_label(task),
845 message: format!("done but worktree has unsaved work: {}", wt_path),
846 fix: FixAction::None,
847 });
848 } else {
849 findings.push(Finding {
850 severity: Severity::Warning,
851 task_id: task_label(task),
852 message: format!("done but worktree not cleaned up: {}", wt_path),
853 fix: FixAction::CleanWorktree { store_id: task.id },
854 });
855 }
856 }
857 }
858 None => {
859 findings.push(Finding {
860 severity: Severity::Warning,
861 task_id: "?".to_string(),
862 message: format!("orphaned worktree (no task owns it): {}", wt_path),
863 fix: FixAction::RemoveOrphanedWorktree {
864 path: wt_path.clone(),
865 },
866 });
867 }
868 }
869 }
870 }
871}
872
873fn check_pr_merged_not_done(
875 task: &Task,
876 pr_states: &std::collections::HashMap<u64, (bool, String)>,
877 findings: &mut Vec<Finding>,
878) {
879 let pr_num = match task.pr_number {
880 Some(n) => n as u64,
881 None => return,
882 };
883
884 if let Some((merged, _)) = pr_states.get(&pr_num) {
885 if *merged {
886 findings.push(Finding {
887 severity: Severity::Error,
888 task_id: task_label(task),
889 message: format!(
890 "PR #{} is merged but task status is '{}'",
891 pr_num,
892 task.status.as_str()
893 ),
894 fix: FixAction::MarkDoneFromMergedPr { store_id: task.id },
895 });
896 }
897 }
898}
899
900async fn check_dead_review_session(
902 task: &Task,
903 repo: &str,
904 tmux: &TmuxManager,
905 findings: &mut Vec<Finding>,
906) {
907 if !task.review_session_expected {
908 return;
909 }
910 let task_id_str = match &task.external_id {
911 Some(id) => id.clone(),
912 None => format!("internal:{}", task.id),
913 };
914 let review_task_id = format!("{}-review", task_id_str);
915 let session = tmux.session_name(repo, &review_task_id);
916 if !tmux.session_exists(&session).await {
917 findings.push(Finding {
918 severity: Severity::Error,
919 task_id: task_label(task),
920 message: format!(
921 "status is 'in_review' with review_session_expected but no review session '{}' found",
922 session
923 ),
924 fix: FixAction::ResetDeadReview { store_id: task.id },
925 });
926 }
927}
928
929async fn worktree_has_work(wt: &Path, branch: &str) -> bool {
931 if !wt.join(".git").exists() {
932 return false;
933 }
934 let has_dirty = tokio::process::Command::new("git")
935 .args(["status", "--porcelain"])
936 .current_dir(wt)
937 .output()
938 .await
939 .map(|o| !o.stdout.is_empty())
940 .unwrap_or(false);
941 if has_dirty {
942 return true;
943 }
944 if !branch.is_empty() {
945 let has_unpushed = tokio::process::Command::new("git")
946 .args(["log", &format!("origin/{}..HEAD", branch), "--oneline"])
947 .current_dir(wt)
948 .output()
949 .await
950 .map(|o| !o.stdout.is_empty())
951 .unwrap_or(false);
952 if has_unpushed {
953 return true;
954 }
955 }
956 false
957}
958
959async fn apply_fixes(
961 findings: &[Finding],
962 store: &Arc<TaskStore>,
963 repo: &str,
964 backend: &Arc<dyn ExternalBackend>,
965 gh: &GhHttp,
966 dry_run: bool,
967) -> anyhow::Result<()> {
968 let prefix = if dry_run { "[dry-run] " } else { "" };
969 let mut fixed = 0;
970 let mut skipped = 0;
971
972 for f in findings {
973 match &f.fix {
974 FixAction::None => {
975 skipped += 1;
976 continue;
977 }
978 FixAction::CommitPushCreatePr { store_id } => {
979 let task = store.get(*store_id).await?;
980 let wt = Path::new(&task.worktree);
981 if !wt.exists() {
982 eprintln!(" skip #{}: worktree no longer exists", f.task_id);
983 skipped += 1;
984 continue;
985 }
986
987 if dry_run {
988 println!(
989 " {}would commit + push + create PR + reopen issue + set needs_review for #{}",
990 prefix, f.task_id
991 );
992 fixed += 1;
993 continue;
994 }
995
996 let commit_ok = git_cmd(wt, &["add", "-A"]).await
998 && git_cmd(
999 wt,
1000 &[
1001 "commit",
1002 "-m",
1003 &format!("fix: recover orphaned work for #{}", f.task_id),
1004 ],
1005 )
1006 .await;
1007 if !commit_ok {
1008 eprintln!(" fix failed for #{}: git commit failed", f.task_id);
1009 skipped += 1;
1010 continue;
1011 }
1012
1013 if !git_cmd(
1015 wt,
1016 &[
1017 "-c",
1018 "lfs.locksverify=false",
1019 "push",
1020 "-u",
1021 "origin",
1022 &task.branch,
1023 ],
1024 )
1025 .await
1026 {
1027 eprintln!(" fix failed for #{}: git push failed", f.task_id);
1028 skipped += 1;
1029 continue;
1030 }
1031
1032 match create_pr_for_task(gh, repo, &task).await {
1034 Ok(pr_num) => {
1035 let _ = store
1036 .set_fields(
1037 *store_id,
1038 &[("pr_number", serde_json::json!(pr_num as i64))],
1039 )
1040 .await;
1041 match reopen_and_set_needs_review(
1043 store, *store_id, &task, backend, gh, repo,
1044 )
1045 .await
1046 {
1047 Ok(()) => {
1048 println!(
1049 " fixed #{}: committed + pushed + created PR #{} + reopened + needs_review",
1050 f.task_id, pr_num
1051 );
1052 fixed += 1;
1053 }
1054 Err(e) => {
1055 eprintln!(
1056 " fix failed for #{}: PR #{} created but status/reopen failed: {e}",
1057 f.task_id, pr_num
1058 );
1059 skipped += 1;
1060 }
1061 }
1062 }
1063 Err(e) => {
1064 eprintln!(
1067 " PR creation error for #{}: {}. Re-checking for existing PR...",
1068 f.task_id, e
1069 );
1070 match gh.get_pr_number(repo, &task.branch).await {
1071 Ok(Some(pr_num)) => {
1072 let _ = store
1073 .set_fields(
1074 *store_id,
1075 &[("pr_number", serde_json::json!(pr_num as i64))],
1076 )
1077 .await;
1078 match reopen_and_set_needs_review(
1079 store, *store_id, &task, backend, gh, repo,
1080 )
1081 .await
1082 {
1083 Ok(()) => {
1084 println!(
1085 " fixed #{}: PR was present on GitHub as #{} (linked) + reopened + needs_review",
1086 f.task_id, pr_num
1087 );
1088 fixed += 1;
1089 }
1090 Err(e2) => {
1091 eprintln!(
1092 " fix failed for #{}: PR #{} linked but status/reopen failed: {e2}",
1093 f.task_id, pr_num
1094 );
1095 skipped += 1;
1096 }
1097 }
1098 }
1099 Ok(None) => {
1100 eprintln!(" fix failed for #{}: PR creation failed and no PR found for branch", f.task_id);
1101 skipped += 1;
1102 }
1103 Err(err2) => {
1104 eprintln!(" fix failed for #{}: PR creation failed: {}; and PR lookup failed: {}", f.task_id, e, err2);
1105 skipped += 1;
1106 }
1107 }
1108 }
1109 }
1110 }
1111 FixAction::PushCreatePr { store_id } => {
1112 let task = store.get(*store_id).await?;
1113 let wt = Path::new(&task.worktree);
1114 if !wt.exists() {
1115 eprintln!(" skip #{}: worktree no longer exists", f.task_id);
1116 skipped += 1;
1117 continue;
1118 }
1119
1120 if dry_run {
1121 println!(
1122 " {}would push + create PR + reopen issue + set needs_review for #{}",
1123 prefix, f.task_id
1124 );
1125 fixed += 1;
1126 continue;
1127 }
1128
1129 if !git_cmd(wt, &["push", "-u", "origin", &task.branch]).await {
1130 eprintln!(" fix failed for #{}: git push failed", f.task_id);
1131 skipped += 1;
1132 continue;
1133 }
1134
1135 match create_pr_for_task(gh, repo, &task).await {
1136 Ok(pr_num) => {
1137 let _ = store
1138 .set_fields(
1139 *store_id,
1140 &[("pr_number", serde_json::json!(pr_num as i64))],
1141 )
1142 .await;
1143 match reopen_and_set_needs_review(
1144 store, *store_id, &task, backend, gh, repo,
1145 )
1146 .await
1147 {
1148 Ok(()) => {
1149 println!(
1150 " fixed #{}: pushed + created PR #{} + reopened + needs_review",
1151 f.task_id, pr_num
1152 );
1153 fixed += 1;
1154 }
1155 Err(e) => {
1156 eprintln!(
1157 " fix failed for #{}: PR #{} created but status/reopen failed: {e}",
1158 f.task_id, pr_num
1159 );
1160 skipped += 1;
1161 }
1162 }
1163 }
1164 Err(e) => {
1165 eprintln!(
1166 " PR creation error for #{}: {}. Re-checking for existing PR...",
1167 f.task_id, e
1168 );
1169 match gh.get_pr_number(repo, &task.branch).await {
1170 Ok(Some(pr_num)) => {
1171 let _ = store
1172 .set_fields(
1173 *store_id,
1174 &[("pr_number", serde_json::json!(pr_num as i64))],
1175 )
1176 .await;
1177 match reopen_and_set_needs_review(
1178 store, *store_id, &task, backend, gh, repo,
1179 )
1180 .await
1181 {
1182 Ok(()) => {
1183 println!(
1184 " fixed #{}: PR was present on GitHub as #{} (linked) + reopened + needs_review",
1185 f.task_id, pr_num
1186 );
1187 fixed += 1;
1188 }
1189 Err(e2) => {
1190 eprintln!(
1191 " fix failed for #{}: PR #{} linked but status/reopen failed: {e2}",
1192 f.task_id, pr_num
1193 );
1194 skipped += 1;
1195 }
1196 }
1197 }
1198 Ok(None) => {
1199 eprintln!(" fix failed for #{}: PR creation failed and no PR found for branch", f.task_id);
1200 skipped += 1;
1201 }
1202 Err(err2) => {
1203 eprintln!(" fix failed for #{}: PR creation failed: {}; and PR lookup failed: {}", f.task_id, e, err2);
1204 skipped += 1;
1205 }
1206 }
1207 }
1208 }
1209 }
1210 FixAction::CreatePr { store_id } => {
1211 let task = store.get(*store_id).await?;
1212
1213 if dry_run {
1214 println!(
1215 " {}would create PR + reopen issue + set needs_review for #{}",
1216 prefix, f.task_id
1217 );
1218 fixed += 1;
1219 continue;
1220 }
1221
1222 match create_pr_for_task(gh, repo, &task).await {
1223 Ok(pr_num) => {
1224 let _ = store
1225 .set_fields(
1226 *store_id,
1227 &[("pr_number", serde_json::json!(pr_num as i64))],
1228 )
1229 .await;
1230 match reopen_and_set_needs_review(
1231 store, *store_id, &task, backend, gh, repo,
1232 )
1233 .await
1234 {
1235 Ok(()) => {
1236 println!(
1237 " fixed #{}: created PR #{} + reopened + needs_review",
1238 f.task_id, pr_num
1239 );
1240 fixed += 1;
1241 }
1242 Err(e) => {
1243 eprintln!(
1244 " fix failed for #{}: PR #{} created but status/reopen failed: {e}",
1245 f.task_id, pr_num
1246 );
1247 skipped += 1;
1248 }
1249 }
1250 }
1251 Err(e) => {
1252 eprintln!(
1253 " PR creation error for #{}: {}. Re-checking for existing PR...",
1254 f.task_id, e
1255 );
1256 match gh.get_pr_number(repo, &task.branch).await {
1257 Ok(Some(pr_num)) => {
1258 let _ = store
1259 .set_fields(
1260 *store_id,
1261 &[("pr_number", serde_json::json!(pr_num as i64))],
1262 )
1263 .await;
1264 match reopen_and_set_needs_review(
1265 store, *store_id, &task, backend, gh, repo,
1266 )
1267 .await
1268 {
1269 Ok(()) => {
1270 println!(
1271 " fixed #{}: PR was present on GitHub as #{} (linked) + reopened + needs_review",
1272 f.task_id, pr_num
1273 );
1274 fixed += 1;
1275 }
1276 Err(e2) => {
1277 eprintln!(
1278 " fix failed for #{}: PR #{} linked but status/reopen failed: {e2}",
1279 f.task_id, pr_num
1280 );
1281 skipped += 1;
1282 }
1283 }
1284 }
1285 Ok(None) => {
1286 eprintln!(" fix failed for #{}: PR creation failed and no PR found for branch", f.task_id);
1287 skipped += 1;
1288 }
1289 Err(err2) => {
1290 eprintln!(" fix failed for #{}: PR creation failed: {}; and PR lookup failed: {}", f.task_id, e, err2);
1291 skipped += 1;
1292 }
1293 }
1294 }
1295 }
1296 }
1297 FixAction::LinkPrAndReopen {
1298 store_id,
1299 pr_number,
1300 } => {
1301 let task = store.get(*store_id).await?;
1302
1303 if dry_run {
1304 println!(
1305 " {}would link PR #{} + reopen issue + set needs_review for #{}",
1306 prefix, pr_number, f.task_id
1307 );
1308 fixed += 1;
1309 continue;
1310 }
1311
1312 let _ = store
1313 .set_fields(
1314 *store_id,
1315 &[("pr_number", serde_json::json!(*pr_number as i64))],
1316 )
1317 .await;
1318 match reopen_and_set_needs_review(store, *store_id, &task, backend, gh, repo).await
1319 {
1320 Ok(()) => {
1321 println!(
1322 " fixed #{}: linked PR #{} + reopened + needs_review",
1323 f.task_id, pr_number
1324 );
1325 fixed += 1;
1326 }
1327 Err(e) => {
1328 eprintln!(
1329 " fix failed for #{}: PR #{} linked but status/reopen failed: {e}",
1330 f.task_id, pr_number
1331 );
1332 skipped += 1;
1333 }
1334 }
1335 }
1336 FixAction::LinkPr {
1337 store_id,
1338 pr_number,
1339 } => {
1340 if dry_run {
1341 println!(
1342 " {}would link PR #{} to task #{}",
1343 prefix, pr_number, f.task_id
1344 );
1345 fixed += 1;
1346 continue;
1347 }
1348
1349 let _ = store
1350 .set_fields(
1351 *store_id,
1352 &[("pr_number", serde_json::json!(*pr_number as i64))],
1353 )
1354 .await;
1355 println!(" fixed #{}: linked PR #{}", f.task_id, pr_number);
1356 fixed += 1;
1357 }
1358 FixAction::ReopenIssue { store_id } => {
1359 let task = store.get(*store_id).await?;
1360
1361 if dry_run {
1362 println!(
1363 " {}would reopen GitHub issue + sync labels for #{}",
1364 prefix, f.task_id
1365 );
1366 fixed += 1;
1367 continue;
1368 }
1369
1370 if let Some(ref ext_id) = task.external_id {
1371 if let Err(e) = gh.reopen_issue(repo, ext_id).await {
1372 eprintln!(" fix failed for #{}: reopen failed: {}", f.task_id, e);
1373 skipped += 1;
1374 continue;
1375 }
1376 let status = task_status_to_backend_status(task.status);
1377 let _ = backend
1378 .update_status(&ExternalId(ext_id.clone()), status)
1379 .await;
1380 println!(" fixed #{}: reopened issue + synced labels", f.task_id);
1381 fixed += 1;
1382 } else {
1383 skipped += 1;
1384 }
1385 }
1386 FixAction::CloseIssue { store_id } => {
1387 let task = store.get(*store_id).await?;
1388
1389 if dry_run {
1390 println!(" {}would close GitHub issue #{}", prefix, f.task_id);
1391 fixed += 1;
1392 continue;
1393 }
1394
1395 if let Some(ref ext_id) = task.external_id {
1396 let status = crate::backends::Status::Done;
1397 if let Err(e) = backend
1398 .update_status(&ExternalId(ext_id.clone()), status)
1399 .await
1400 {
1401 eprintln!(" fix failed for #{}: {}", f.task_id, e);
1402 skipped += 1;
1403 } else {
1404 println!(" fixed #{}: closed GitHub issue", f.task_id);
1405 fixed += 1;
1406 }
1407 } else {
1408 skipped += 1;
1409 }
1410 }
1411 FixAction::SyncLabels { store_id } => {
1412 let task = store.get(*store_id).await?;
1413
1414 if dry_run {
1415 println!(
1416 " {}would sync labels to status:{} for #{}",
1417 prefix,
1418 task.status.as_str(),
1419 f.task_id
1420 );
1421 fixed += 1;
1422 continue;
1423 }
1424
1425 if let Some(ref ext_id) = task.external_id {
1426 let status = task_status_to_backend_status(task.status);
1427 if let Err(e) = backend
1428 .update_status(&ExternalId(ext_id.clone()), status)
1429 .await
1430 {
1431 eprintln!(" fix failed for #{}: {}", f.task_id, e);
1432 skipped += 1;
1433 } else {
1434 println!(" fixed #{}: synced labels", f.task_id);
1435 fixed += 1;
1436 }
1437 } else {
1438 skipped += 1;
1439 }
1440 }
1441 FixAction::ResetToRouted { store_id } => {
1442 if dry_run {
1443 println!(
1444 " {}would reset #{} to 'routed' (re-dispatch on next tick)",
1445 prefix, f.task_id
1446 );
1447 fixed += 1;
1448 continue;
1449 }
1450
1451 if let Err(e) = store.update_status(*store_id, TaskStatus::Routed).await {
1452 eprintln!(" fix failed for #{}: {}", f.task_id, e);
1453 skipped += 1;
1454 continue;
1455 }
1456
1457 let task = store.get(*store_id).await?;
1459 if let Some(ref ext_id) = task.external_id {
1460 let _ = backend
1461 .update_status(&ExternalId(ext_id.clone()), crate::backends::Status::Routed)
1462 .await;
1463 }
1464 println!(" fixed #{}: reset to routed", f.task_id);
1465 fixed += 1;
1466 }
1467 FixAction::CleanWorktree { store_id } => {
1468 let task = store.get(*store_id).await?;
1469
1470 if dry_run {
1471 println!(
1472 " {}would clean worktree + mark cleaned for #{}",
1473 prefix, f.task_id
1474 );
1475 fixed += 1;
1476 continue;
1477 }
1478
1479 let wt = Path::new(&task.worktree);
1480 if wt.exists() {
1481 match resolve_repo_root(repo).await {
1482 Ok(root) => {
1483 let branch = if task.branch.is_empty() {
1484 None
1485 } else {
1486 Some(task.branch.as_str())
1487 };
1488 let removed = remove_worktree_and_branch(
1489 &f.task_id,
1490 wt,
1491 branch,
1492 Path::new(&root),
1493 task.pr_number.is_some(), )
1495 .await;
1496 if removed {
1497 let _ = store.mark_cleaned(*store_id).await;
1498 println!(" fixed #{}: cleaned worktree", f.task_id);
1499 fixed += 1;
1500 } else {
1501 eprintln!(
1502 " fix failed for #{}: worktree removal failed",
1503 f.task_id
1504 );
1505 skipped += 1;
1506 }
1507 }
1508 Err(e) => {
1509 eprintln!(
1510 " fix failed for #{}: cannot resolve repo root: {}",
1511 f.task_id, e
1512 );
1513 skipped += 1;
1514 }
1515 }
1516 } else {
1517 let _ = store.mark_cleaned(*store_id).await;
1519 println!(
1520 " fixed #{}: marked worktree cleaned (already gone)",
1521 f.task_id
1522 );
1523 fixed += 1;
1524 }
1525 }
1526 FixAction::RemoveOrphanedWorktree { path } => {
1527 if dry_run {
1528 println!(" {}would remove orphaned worktree: {}", prefix, path);
1529 fixed += 1;
1530 continue;
1531 }
1532
1533 let removed = cleanup_orphaned_worktree(&f.task_id, Path::new(path)).await;
1534 if removed {
1535 println!(" fixed: removed orphaned worktree {}", path);
1536 fixed += 1;
1537 } else {
1538 eprintln!(" fix failed: could not remove orphaned worktree {}", path);
1539 skipped += 1;
1540 }
1541 }
1542 FixAction::MarkDoneFromMergedPr { store_id } => {
1543 let task = store.get(*store_id).await?;
1544
1545 if dry_run {
1546 println!(
1547 " {}would mark #{} done + close issue + clean worktree",
1548 prefix, f.task_id
1549 );
1550 fixed += 1;
1551 continue;
1552 }
1553
1554 if let Err(e) = store.update_status(*store_id, TaskStatus::Done).await {
1556 eprintln!(" fix failed for #{}: {}", f.task_id, e);
1557 skipped += 1;
1558 continue;
1559 }
1560
1561 if let Some(ref ext_id) = task.external_id {
1563 let _ = backend
1564 .update_status(&ExternalId(ext_id.clone()), crate::backends::Status::Done)
1565 .await;
1566 }
1567
1568 if !task.worktree.is_empty() && !task.worktree_cleaned {
1570 let wt = Path::new(&task.worktree);
1571 if wt.exists() {
1572 if let Ok(root) = resolve_repo_root(repo).await {
1573 let branch = if task.branch.is_empty() {
1574 None
1575 } else {
1576 Some(task.branch.as_str())
1577 };
1578 let removed = remove_worktree_and_branch(
1579 &f.task_id,
1580 wt,
1581 branch,
1582 Path::new(&root),
1583 true, )
1585 .await;
1586 if removed {
1587 let _ = store.mark_cleaned(*store_id).await;
1588 }
1589 }
1590 } else {
1591 let _ = store.mark_cleaned(*store_id).await;
1592 }
1593 }
1594
1595 println!(" fixed #{}: marked done + closed issue", f.task_id);
1596 fixed += 1;
1597 }
1598 FixAction::ResetDeadReview { store_id } => {
1599 if dry_run {
1600 println!(
1601 " {}would reset #{} review_session_expected=0 + set needs_review",
1602 prefix, f.task_id
1603 );
1604 fixed += 1;
1605 continue;
1606 }
1607
1608 let _ = store
1610 .set_fields(
1611 *store_id,
1612 &[("review_session_expected", serde_json::json!(false))],
1613 )
1614 .await;
1615 if let Err(e) = store
1616 .update_status(*store_id, TaskStatus::NeedsReview)
1617 .await
1618 {
1619 eprintln!(" fix failed for #{}: {}", f.task_id, e);
1620 skipped += 1;
1621 continue;
1622 }
1623
1624 let task = store.get(*store_id).await?;
1626 if let Some(ref ext_id) = task.external_id {
1627 let _ = backend
1628 .update_status(
1629 &ExternalId(ext_id.clone()),
1630 crate::backends::Status::NeedsReview,
1631 )
1632 .await;
1633 }
1634 println!(
1635 " fixed #{}: reset review session + set needs_review",
1636 f.task_id
1637 );
1638 fixed += 1;
1639 }
1640 }
1641 }
1642
1643 let action = if dry_run { "would fix" } else { "fixed" };
1644 println!("\n{} {}, skipped {}.", action, fixed, skipped);
1645
1646 if !dry_run && skipped > 0 {
1647 println!("Use `orch task reopen <id>` to manually recover skipped tasks.");
1648 }
1649
1650 Ok(())
1651}
1652
1653async fn reopen_and_set_needs_review(
1659 store: &Arc<TaskStore>,
1660 store_id: i64,
1661 task: &Task,
1662 backend: &Arc<dyn ExternalBackend>,
1663 gh: &GhHttp,
1664 repo: &str,
1665) -> anyhow::Result<()> {
1666 store
1669 .update_status(store_id, TaskStatus::NeedsReview)
1670 .await
1671 .context("failed to update task status to NeedsReview in database")?;
1672
1673 if let Some(ref ext_id) = task.external_id {
1675 gh.reopen_issue(repo, ext_id)
1677 .await
1678 .with_context(|| format!("failed to reopen GitHub issue {ext_id}"))?;
1679
1680 if let Err(e) = backend
1682 .update_status(
1683 &ExternalId(ext_id.clone()),
1684 crate::backends::Status::NeedsReview,
1685 )
1686 .await
1687 {
1688 eprintln!(
1689 " warning: backend label sync failed for #{}: {e}",
1690 task_label(task)
1691 );
1692 }
1693 }
1694
1695 Ok(())
1696}
1697
1698async fn create_pr_for_task(gh: &GhHttp, repo: &str, task: &Task) -> anyhow::Result<u64> {
1700 let title = format!("fix: recover work for #{}", task_label(task));
1701 let body = format!(
1702 "Recovered by `orch service doctor --fix`.\n\nOriginal task: #{}",
1703 task_label(task)
1704 );
1705 let pr_url = gh
1706 .create_pr(repo, &title, &body, &task.branch, "main")
1707 .await?;
1708 let pr_num = pr_url
1710 .rsplit('/')
1711 .next()
1712 .and_then(|s| s.parse::<u64>().ok())
1713 .context("could not parse PR number from URL")?;
1714 Ok(pr_num)
1715}
1716
1717async fn git_cmd(wt: &Path, args: &[&str]) -> bool {
1719 tokio::process::Command::new("git")
1720 .args(args)
1721 .current_dir(wt)
1722 .output()
1723 .await
1724 .map(|o| o.status.success())
1725 .unwrap_or(false)
1726}
1727
1728fn task_label(task: &Task) -> String {
1729 match &task.external_id {
1730 Some(id) => id.clone(),
1731 None => format!("internal:{}", task.id),
1732 }
1733}
1734
1735fn task_status_to_backend_status(status: TaskStatus) -> crate::backends::Status {
1736 match status {
1737 TaskStatus::New => crate::backends::Status::New,
1738 TaskStatus::Routed => crate::backends::Status::Routed,
1739 TaskStatus::InProgress => crate::backends::Status::InProgress,
1740 TaskStatus::Done => crate::backends::Status::Done,
1741 TaskStatus::Blocked => crate::backends::Status::Blocked,
1742 TaskStatus::InReview => crate::backends::Status::InReview,
1743 TaskStatus::NeedsReview => crate::backends::Status::NeedsReview,
1744 TaskStatus::Waiting => crate::backends::Status::Waiting,
1745 }
1746}
1747
1748fn collect_valid_external_issue_numbers<'a>(
1749 tasks: impl IntoIterator<Item = &'a Task>,
1750 repo: &str,
1751 caller_context: &str,
1752) -> Vec<u64> {
1753 let mut issue_numbers = Vec::new();
1754
1755 for task in tasks {
1756 let Some(external_id) = task.external_id.as_deref() else {
1757 continue;
1758 };
1759
1760 match external_id.parse::<u64>() {
1761 Ok(0) => {
1762 tracing::warn!(
1763 repo,
1764 caller_context,
1765 store_id = task.id,
1766 external_id,
1767 "dropping invalid external_id before batch issue-state query"
1768 );
1769 }
1770 Ok(issue_number) => issue_numbers.push(issue_number),
1771 Err(error) => {
1772 tracing::warn!(
1773 repo,
1774 caller_context,
1775 store_id = task.id,
1776 external_id,
1777 error = %error,
1778 "skipping non-numeric external_id in batch issue-state query"
1779 );
1780 }
1781 }
1782 }
1783
1784 issue_numbers.sort_unstable();
1785 issue_numbers.dedup();
1786 issue_numbers
1787}
1788
1789#[cfg(test)]
1790mod tests {
1791 use super::collect_valid_external_issue_numbers;
1792 use crate::store::{Task, TaskStatus};
1793
1794 fn task(id: i64, external_id: Option<&str>, origin: &str) -> Task {
1795 Task {
1796 id,
1797 external_id: external_id.map(ToOwned::to_owned),
1798 repo: "owner/repo".to_string(),
1799 origin: origin.to_string(),
1800 title: String::new(),
1801 body: String::new(),
1802 status: TaskStatus::New,
1803 source: String::new(),
1804 source_id: String::new(),
1805 author: String::new(),
1806 url: String::new(),
1807 labels: Vec::new(),
1808 agent: None,
1809 model: None,
1810 complexity: String::new(),
1811 estimate: 0,
1812 route_reason: String::new(),
1813 agent_profile: String::new(),
1814 selected_skills: String::new(),
1815 route_attempts: 0,
1816 attempts: 0,
1817 branch: String::new(),
1818 worktree: String::new(),
1819 worktree_cleaned: false,
1820 summary: String::new(),
1821 last_error: String::new(),
1822 parent_id: None,
1823 block_reason: None,
1824 pr_number: None,
1825 pr_review_context: String::new(),
1826 last_review_ts: String::new(),
1827 review_ts_map: String::new(),
1828 last_comment_review_ts: String::new(),
1829 merge_conflict_retries: 0,
1830 ci_merge_failures: 0,
1831 pr_create_failures: 0,
1832 push_failures: 0,
1833 network_retries: 0,
1834 review_agent_failures: 0,
1835 review_cycles: 0,
1836 review_invocations: 0,
1837 review_session_expected: false,
1838 needs_review_refires: 0,
1839 input_tokens: 0,
1840 output_tokens: 0,
1841 input_cost_usd: 0.0,
1842 output_cost_usd: 0.0,
1843 total_cost_usd: 0.0,
1844 model_reroute_chain: String::new(),
1845 limit_reroute_chain: String::new(),
1846 memory: Vec::new(),
1847 delegations: Vec::new(),
1848 auto_unblock_count: 0,
1849 auto_unblock_last_at: String::new(),
1850 auto_unblock_last_reason: String::new(),
1851 ci_recovery_count: 0,
1852 no_code_reroutes: 0,
1853 no_code_last_agent: String::new(),
1854 created_at: String::new(),
1855 updated_at: String::new(),
1856 }
1857 }
1858
1859 #[test]
1860 fn collect_valid_external_issue_numbers_filters_zero_and_non_numeric_ids() {
1861 let tasks = [
1862 task(1, Some("42"), "github"),
1863 task(2, Some("0"), "github"),
1864 task(3, Some("not-a-number"), "github"),
1865 task(4, Some("42"), "github"),
1866 ];
1867
1868 let issue_numbers =
1869 collect_valid_external_issue_numbers(tasks.iter(), "owner/repo", "test");
1870
1871 assert_eq!(issue_numbers, vec![42]);
1872 }
1873}