1use crate::cli::IssueCommands;
4use anyhow::Result;
5
6pub 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 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
115async 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
137async 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 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}