Skip to main content

oxicode/cli/commands/
issue.rs

1//! Issue subcommand handler.
2
3use crate::cli::IssueCommands;
4use anyhow::Result;
5
6/// Handle `oxicode 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 oxicode_sdk::format_issue_full;
10    use oxicode_sdk::{IssueFilter, Priority, Status};
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 = oxicode_sdk::issues_dir(&cwd);
19        let removed = oxicode_sdk::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 = oxicode_sdk::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!("{}", oxicode_sdk::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, &store.issues_dir()));
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 = oxicode_sdk::liveness::acquire(&store.issues_dir(), &session).ok();
80            let (issue, current_hash) = store.read(*id)?;
81            if let Some(a) = &issue.meta.assigned_to
82                && a.session != session
83                && oxicode_sdk::liveness::is_session_alive(&store.issues_dir(), &a.session)
84            {
85                anyhow::bail!(
86                    "issue #{id} is currently being worked on by session {} (since {}); cannot close from CLI",
87                    a.session,
88                    a.acquired_at,
89                );
90            }
91            let effective_hash = hash.clone().unwrap_or(current_hash);
92            let closed = close_issue(&store, *id, &session, Some(effective_hash))
93                .await
94                .map_err(|e| anyhow::anyhow!(e.to_string()))?;
95            println!("closed issue #{}: {}", closed.meta.id, closed.meta.title);
96        }
97        IssueCommands::Reopen { id, hash } => {
98            let effective_hash = match hash.clone() {
99                Some(h) => h,
100                None => store.read(*id)?.1,
101            };
102            let reopened = reopen_issue(&store, *id, Some(effective_hash))
103                .await
104                .map_err(|e| anyhow::anyhow!(e.to_string()))?;
105            println!(
106                "reopened issue #{}: {}",
107                reopened.meta.id, reopened.meta.title
108            );
109        }
110        IssueCommands::Reap => unreachable!("reap handled before store open"),
111    }
112    Ok(())
113}
114
115/// Claim + close in one bounded-CAS sequence (mirrors the TUI panel's
116/// dispatcher: `start` → re-read a fresh hash → `close`). The CLI is a
117/// single-shot caller, but a stale hash from an earlier `read` is still
118/// advisory — `cas_retry` re-reads and retries instead of failing.
119async fn close_issue(
120    store: &oxicode_sdk::FileIssueStore,
121    id: u32,
122    session: &str,
123    hash: Option<String>,
124) -> std::result::Result<oxicode_sdk::Issue, oxicode_sdk::IssueError> {
125    oxicode_sdk::cas_retry(store, id, hash, |h| {
126        let store = store.clone();
127        let session = session.to_string();
128        async move {
129            store.start(id, &session, h).await?;
130            let (_, fresh_hash) = store.read(id)?;
131            store.close(id, &session, Some(fresh_hash)).await
132        }
133    })
134    .await
135}
136
137/// Reopen under bounded-CAS retry (same stale-hash recovery as
138/// [`close_issue`]).
139async fn reopen_issue(
140    store: &oxicode_sdk::FileIssueStore,
141    id: u32,
142    hash: Option<String>,
143) -> std::result::Result<oxicode_sdk::Issue, oxicode_sdk::IssueError> {
144    oxicode_sdk::cas_retry(store, id, hash, |h| {
145        let store = store.clone();
146        async move { store.reopen(id, h).await }
147    })
148    .await
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154
155    fn tmp_store() -> (tempfile::TempDir, oxicode_sdk::FileIssueStore) {
156        let tmp = tempfile::tempdir().unwrap();
157        let dir = tmp.path().join(".oxicode").join("issues");
158        std::fs::create_dir_all(&dir).unwrap();
159        (tmp, oxicode_sdk::FileIssueStore::open(dir).unwrap())
160    }
161
162    #[tokio::test]
163    async fn close_issue_recovers_from_stale_hash() {
164        let (_tmp, store) = tmp_store();
165        store
166            .create(
167                "T".into(),
168                "b".into(),
169                oxicode_sdk::Priority::Low,
170                vec![],
171                None,
172            )
173            .unwrap();
174        let (_, stale) = store.read(1).unwrap();
175        // Self-claim mutates the file → `stale` is now outdated.
176        store.start(1, "cli-test", None).await.unwrap();
177
178        let closed = close_issue(&store, 1, "cli-test", Some(stale))
179            .await
180            .expect("stale hash must be recovered by CAS retry");
181        assert_eq!(closed.meta.status, oxicode_sdk::Status::Closed);
182    }
183
184    #[tokio::test]
185    async fn reopen_issue_recovers_from_stale_hash() {
186        let (_tmp, store) = tmp_store();
187        store
188            .create(
189                "T".into(),
190                "b".into(),
191                oxicode_sdk::Priority::Low,
192                vec![],
193                None,
194            )
195            .unwrap();
196        store.start(1, "cli-test", None).await.unwrap();
197        let (_, stale) = store.read(1).unwrap();
198        store
199            .close(1, "cli-test", Some(stale.clone()))
200            .await
201            .unwrap();
202
203        let reopened = reopen_issue(&store, 1, Some(stale))
204            .await
205            .expect("stale hash must be recovered by CAS retry");
206        assert_eq!(reopened.meta.status, oxicode_sdk::Status::Open);
207    }
208}