Skip to main content

oxi/cli/commands/
issue.rs

1//! Issue subcommand handler.
2
3use crate::cli::IssueCommands;
4use anyhow::Result;
5
6/// Handle `oxi issue …` subcommands. Opens the local issue store rooted at
7/// the project and dispatches to the requested action.
8pub async fn handle_issue(action: &IssueCommands) -> Result<()> {
9    use crate::store::issues::{IssueFilter, Priority, Status};
10    use crate::tools::format_issue_full;
11
12    let cwd = std::env::current_dir()?;
13
14    // `reap` is special: it resolves the issues dir WITHOUT opening a store,
15    // because the store constructor runs its own lazy reap — opening one here
16    // would double-reap and underreport the count we actually removed.
17    if let IssueCommands::Reap = action {
18        let dir = crate::store::issues::issues_dir(&cwd);
19        let removed = crate::store::issues::liveness::reap_orphans(&dir)
20            .map_err(|e| anyhow::anyhow!(e.to_string()))?;
21        println!("reaped {removed} dead alive-lock file(s)");
22        return Ok(());
23    }
24
25    let store = crate::store::issues::FileIssueStore::open_from_cwd(&cwd)?;
26
27    match action {
28        IssueCommands::List { all, label, text } => {
29            let filter = IssueFilter {
30                status: if *all { None } else { Some(Status::Open) },
31                priority: None,
32                label: label.clone(),
33                assigned_to_session: None,
34                text: text.clone(),
35            };
36            let issues = store.list(&filter)?;
37            if issues.is_empty() {
38                println!("(no issues)");
39            } else {
40                for i in &issues {
41                    println!("{}", crate::tools::format_issue_line(i));
42                }
43            }
44        }
45        IssueCommands::Show { id } => {
46            let (issue, hash) = store.read(*id)?;
47            println!("{}", format_issue_full(&issue, &hash));
48        }
49        IssueCommands::New {
50            title,
51            body,
52            priority,
53            labels,
54        } => {
55            let body = body.clone().unwrap_or_default();
56            let prio = match priority.as_deref() {
57                Some("low") => Priority::Low,
58                Some("medium") | None => Priority::Medium,
59                Some("high") => Priority::High,
60                Some("critical") => Priority::Critical,
61                Some(other) => anyhow::bail!("invalid priority: {other}"),
62            };
63            let labels: Vec<String> = labels
64                .as_deref()
65                .map(|s| s.split(',').map(|l| l.trim().to_string()).collect())
66                .unwrap_or_default();
67            let issue = store.create(title.clone(), body, prio, labels, None)?;
68            println!("created issue #{}: {}", issue.meta.id, issue.meta.title);
69        }
70        IssueCommands::Close { id, hash } => {
71            let session = format!(
72                "cli-{}-{}",
73                std::process::id(),
74                std::time::SystemTime::now()
75                    .duration_since(std::time::UNIX_EPOCH)
76                    .map(|d| d.as_nanos())
77                    .unwrap_or(0)
78            );
79            let _guard =
80                crate::store::issues::liveness::acquire(&store.issues_dir(), &session).ok();
81            let (issue, current_hash) = store.read(*id)?;
82            if let Some(a) = &issue.meta.assigned_to
83                && a.session != session
84                && crate::store::issues::liveness::is_session_alive(&store.issues_dir(), &a.session)
85            {
86                anyhow::bail!(
87                    "issue #{id} is currently being worked on by session {} (since {}); cannot close from CLI",
88                    a.session,
89                    a.acquired_at,
90                );
91            }
92            let effective_hash = hash.clone().unwrap_or(current_hash);
93            store
94                .start(*id, &session, Some(effective_hash))
95                .await
96                .map_err(|e| anyhow::anyhow!(e.to_string()))?;
97            let (_, fresh_hash) = store.read(*id)?;
98            let closed = store
99                .close(*id, &session, Some(fresh_hash))
100                .await
101                .map_err(|e| anyhow::anyhow!(e.to_string()))?;
102            println!("closed issue #{}: {}", closed.meta.id, closed.meta.title);
103        }
104        IssueCommands::Reopen { id, hash } => {
105            let effective_hash = match hash.clone() {
106                Some(h) => h,
107                None => store.read(*id)?.1,
108            };
109            let reopened = store
110                .reopen(*id, Some(effective_hash))
111                .await
112                .map_err(|e| anyhow::anyhow!(e.to_string()))?;
113            println!(
114                "reopened issue #{}: {}",
115                reopened.meta.id, reopened.meta.title
116            );
117        }
118        IssueCommands::Reap => unreachable!("reap handled before store open"),
119    }
120    Ok(())
121}