1use std::sync::Arc;
20
21use async_trait::async_trait;
22use oxi_agent::{AgentTool, AgentToolResult, ToolContext};
23use serde_json::{Value, json};
24
25use crate::store::issues::{FileIssueStore, Issue, IssueError, IssueFilter, Priority, Status};
26
27#[derive(Debug, Clone)]
29pub struct IssueTool {
30 store: Arc<FileIssueStore>,
31}
32
33impl IssueTool {
34 pub fn new(store: FileIssueStore) -> Self {
36 Self {
37 store: Arc::new(store),
38 }
39 }
40}
41
42#[async_trait]
43impl AgentTool for IssueTool {
44 fn name(&self) -> &str {
45 "issue"
46 }
47
48 fn label(&self) -> &str {
49 "Issue"
50 }
51
52 fn description(&self) -> &str {
53 "Manage local issues stored as markdown files in `.oxi/issues/`. \
54 Before editing, call `start` to claim the issue — this prevents other \
55 agents/sessions from concurrently working on the same issue. Always \
56 call `list` first to see existing issues and avoid duplicates. \
57 Use `release` to give up a claim, or `close` to finish the work."
58 }
59
60 fn parameters_schema(&self) -> Value {
61 json!({
62 "type": "object",
63 "properties": {
64 "action": {
65 "type": "string",
66 "enum": ["list", "read", "create", "update", "start", "release", "close", "link_session"],
67 "description": "Which issue operation to perform."
68 },
69 "id": {"type": "integer", "description": "Issue id (for read/update/start/release/close)."},
70 "title": {"type": "string", "description": "Issue title (for create)."},
71 "body": {"type": "string", "description": "Markdown body (for create/update)."},
72 "priority": {"type": "string", "enum": ["low", "medium", "high", "critical"], "description": "Priority."},
73 "labels": {"type": "array", "items": {"type": "string"}, "description": "String labels."},
74 "status": {"type": "string", "enum": ["open", "closed"], "description": "Status filter (for list) or new status (for update)."},
75 "label": {"type": "string", "description": "Filter list to issues with this label."},
76 "text": {"type": "string", "description": "Substring filter on title (list)."},
77 "content_hash": {"type": "string", "description": "Optional hash from the last read; if the file has changed, the write is rejected."}
78 },
79 "required": ["action"]
80 })
81 }
82
83 fn essential(&self) -> bool {
84 false
85 }
86
87 async fn execute(
88 &self,
89 _tool_call_id: &str,
90 params: Value,
91 _signal: Option<tokio::sync::oneshot::Receiver<()>>,
92 ctx: &ToolContext,
93 ) -> Result<AgentToolResult, String> {
94 let action = match params.get("action").and_then(|v| v.as_str()) {
95 Some(a) => a.to_string(),
96 None => return Ok(AgentToolResult::error("missing required field: action")),
97 };
98
99 let session = ctx.session_id.clone().unwrap_or_default();
100 let result: Result<String, String> = match action.as_str() {
101 "list" => self.list(params),
102 "read" => self.read(params).await,
103 "create" => self.create(params, &session).await,
104 "update" => self.update(params, &session).await,
105 "start" => self.start(params, &session).await,
106 "release" => self.release(params, &session).await,
107 "close" => self.close(params, &session).await,
108 "link_session" => self.link_session(params, &session).await,
109 other => Err(format!("unknown action: {other}")),
110 };
111
112 Ok(match result {
113 Ok(text) => AgentToolResult::success(text),
114 Err(e) => AgentToolResult::error(e),
115 })
116 }
117}
118
119impl IssueTool {
120 fn list(&self, params: Value) -> Result<String, String> {
121 let status = parse_status_opt(params.get("status"))?;
122 let priority = parse_priority_opt(params.get("priority"))?;
123 let label = params
124 .get("label")
125 .and_then(|v| v.as_str())
126 .map(String::from);
127 let text = params
128 .get("text")
129 .and_then(|v| v.as_str())
130 .map(String::from);
131 let filter = IssueFilter {
132 status,
133 priority,
134 label,
135 assigned_to_session: None,
136 text,
137 };
138 let issues = self.store.list(&filter).map_err(|e| e.to_string())?;
139 if issues.is_empty() {
140 return Ok("no issues match the filter".to_string());
141 }
142 Ok(issues
143 .iter()
144 .map(format_issue_line)
145 .collect::<Vec<_>>()
146 .join("\n"))
147 }
148
149 async fn read(&self, params: Value) -> Result<String, String> {
150 let id = require_u32(params.get("id"), "id")?;
151 self.store
152 .read(id)
153 .map(|(issue, hash)| format_issue_full(&issue, &hash))
154 .map_err(|e| e.to_string())
155 }
156
157 async fn create(&self, params: Value, session: &str) -> Result<String, String> {
158 let title = require_string(params.get("title"), "title")?;
159 let body = params
160 .get("body")
161 .and_then(|v| v.as_str())
162 .unwrap_or("")
163 .to_string();
164 let priority = parse_priority_opt(params.get("priority"))?.unwrap_or(Priority::Medium);
165 let labels = parse_labels(params.get("labels"))?;
166 let session_opt = if session.is_empty() {
167 None
168 } else {
169 Some(session)
170 };
171 let issue = self
172 .store
173 .create(title, body, priority, labels, session_opt)
174 .map_err(|e| e.to_string())?;
175 Ok(format!(
176 "created issue #{}: {}",
177 issue.meta.id, issue.meta.title
178 ))
179 }
180
181 async fn update(&self, params: Value, session: &str) -> Result<String, String> {
182 let id = require_u32(params.get("id"), "id")?;
183 let hash = params
184 .get("content_hash")
185 .and_then(|v| v.as_str())
186 .map(String::from);
187 let new_title = params
188 .get("title")
189 .and_then(|v| v.as_str())
190 .map(String::from);
191 let new_body = params
192 .get("body")
193 .and_then(|v| v.as_str())
194 .map(String::from);
195 let new_priority = parse_priority_opt(params.get("priority"))?;
196 let new_status = parse_status_opt(params.get("status"))?;
197 let new_labels = parse_labels(params.get("labels"))?;
198 let labels_changed = params.get("labels").is_some();
199 let session_owned = session.to_string();
200
201 let store = self.store.clone();
202 let result = store
203 .update(id, hash, move |mut issue| {
204 if let Some(ref a) = issue.meta.assigned_to
205 && !a.session.is_empty()
206 && a.session != session_owned
207 {
208 return Err(IssueError::NotAssigned {
209 id,
210 caller: session_owned,
211 });
212 }
213 if let Some(t) = new_title {
214 issue.meta.title = t;
215 }
216 if let Some(b) = new_body {
217 issue.body = b;
218 }
219 if let Some(p) = new_priority {
220 issue.meta.priority = p;
221 }
222 if let Some(s) = new_status {
223 issue.meta.status = s;
224 if s == Status::Closed {
225 issue.meta.closed_at = Some(chrono::Utc::now());
226 }
227 }
228 if labels_changed {
229 issue.meta.labels = new_labels;
230 }
231 Ok(issue)
232 })
233 .await;
234 match result {
235 Ok(issue) => Ok(format!("updated issue #{}", issue.meta.id)),
236 Err(e) => Err(e.to_string()),
237 }
238 }
239
240 async fn start(&self, params: Value, session: &str) -> Result<String, String> {
241 let id = require_u32(params.get("id"), "id")?;
242 let hash = params
243 .get("content_hash")
244 .and_then(|v| v.as_str())
245 .map(String::from);
246 if session.is_empty() {
247 return Err("cannot start: no active session id in context".to_string());
248 }
249 self.store
250 .start(id, session, hash)
251 .await
252 .map(|issue| format!("assigned issue #{} to session {}", issue.meta.id, session))
253 .map_err(|e| e.to_string())
254 }
255
256 async fn release(&self, params: Value, session: &str) -> Result<String, String> {
257 let id = require_u32(params.get("id"), "id")?;
258 let hash = params
259 .get("content_hash")
260 .and_then(|v| v.as_str())
261 .map(String::from);
262 if session.is_empty() {
263 return Err("cannot release: no active session id in context".to_string());
264 }
265 self.store
266 .release(id, session, hash)
267 .await
268 .map(|_| format!("released issue #{id}"))
269 .map_err(|e| e.to_string())
270 }
271
272 async fn close(&self, params: Value, session: &str) -> Result<String, String> {
273 let id = require_u32(params.get("id"), "id")?;
274 let hash = params
275 .get("content_hash")
276 .and_then(|v| v.as_str())
277 .map(String::from);
278 if session.is_empty() {
279 return Err("cannot close: no active session id in context".to_string());
280 }
281 self.store
282 .close(id, session, hash)
283 .await
284 .map(|issue| format!("closed issue #{}: {}", issue.meta.id, issue.meta.title))
285 .map_err(|e| e.to_string())
286 }
287
288 async fn link_session(&self, params: Value, session: &str) -> Result<String, String> {
289 let id = require_u32(params.get("id"), "id")?;
290 let hash = params
291 .get("content_hash")
292 .and_then(|v| v.as_str())
293 .map(String::from);
294 if session.is_empty() {
295 return Err("cannot link_session: no active session id in context".to_string());
296 }
297 self.store
298 .link_session(id, session, hash)
299 .await
300 .map(|_| format!("linked session to issue #{id}"))
301 .map_err(|e| e.to_string())
302 }
303}
304
305pub fn format_issue_line(i: &Issue) -> String {
310 let lock = if i.meta.assigned_to.is_some() {
311 "🔒"
312 } else {
313 " "
314 };
315 let assignee = i
316 .meta
317 .assigned_to
318 .as_ref()
319 .map(|a| format!(" (assigned: {})", short_session(&a.session)))
320 .unwrap_or_default();
321 format!(
322 "#{:<4} [{}] {:8} {}{} {}{}",
323 i.meta.id,
324 i.meta.status,
325 i.meta.priority,
326 lock,
327 i.meta.title,
328 i.meta.labels.join(","),
329 assignee,
330 )
331}
332
333pub fn format_issue_full(i: &Issue, hash: &str) -> String {
336 let mut s = format_issue_line(i);
337 s.push('\n');
338 s.push_str(&format!(" id: {}\n", i.meta.id));
339 s.push_str(&format!(" created: {}\n", i.meta.created_at));
340 s.push_str(&format!(" updated: {}\n", i.meta.updated_at));
341 if let Some(c) = i.meta.closed_at {
342 s.push_str(&format!(" closed: {}\n", c));
343 }
344 s.push_str(&format!(" sessions: {:?}\n", i.meta.sessions));
345 if let Some(a) = &i.meta.assigned_to {
346 s.push_str(&format!(
347 " assigned: {} (since {})\n",
348 short_session(&a.session),
349 a.acquired_at
350 ));
351 }
352 s.push_str(&format!(" content_hash: {}\n", hash));
353 s.push('\n');
354 s.push_str(&i.body);
355 s
356}
357
358fn short_session(s: &str) -> String {
359 if s.len() <= 8 {
360 s.to_string()
361 } else {
362 format!("{}…", &s[..8])
363 }
364}
365
366fn require_string(v: Option<&Value>, name: &str) -> Result<String, String> {
369 v.and_then(|x| x.as_str())
370 .map(String::from)
371 .ok_or_else(|| format!("missing required field: {name}"))
372}
373
374fn require_u32(v: Option<&Value>, name: &str) -> Result<u32, String> {
375 v.and_then(|x| x.as_u64())
376 .and_then(|n| u32::try_from(n).ok())
377 .ok_or_else(|| format!("missing or invalid field: {name}"))
378}
379
380fn parse_status_opt(v: Option<&Value>) -> Result<Option<Status>, String> {
381 let Some(v) = v else { return Ok(None) };
382 let s = v
383 .as_str()
384 .ok_or_else(|| "status must be a string".to_string())?;
385 match s {
386 "open" => Ok(Some(Status::Open)),
387 "closed" => Ok(Some(Status::Closed)),
388 other => Err(format!("invalid status: {other}")),
389 }
390}
391
392fn parse_priority_opt(v: Option<&Value>) -> Result<Option<Priority>, String> {
393 let Some(v) = v else { return Ok(None) };
394 let s = v
395 .as_str()
396 .ok_or_else(|| "priority must be a string".to_string())?;
397 match s {
398 "low" => Ok(Some(Priority::Low)),
399 "medium" => Ok(Some(Priority::Medium)),
400 "high" => Ok(Some(Priority::High)),
401 "critical" => Ok(Some(Priority::Critical)),
402 other => Err(format!("invalid priority: {other}")),
403 }
404}
405
406fn parse_labels(v: Option<&Value>) -> Result<Vec<String>, String> {
407 let Some(v) = v else { return Ok(vec![]) };
408 let arr = v
409 .as_array()
410 .ok_or_else(|| "labels must be an array of strings".to_string())?;
411 let mut out = Vec::with_capacity(arr.len());
412 for item in arr {
413 let s = item
414 .as_str()
415 .ok_or_else(|| "labels must be an array of strings".to_string())?;
416 out.push(s.to_string());
417 }
418 Ok(out)
419}