Skip to main content

oxi/tools/
issue_tool.rs

1//! `issue` agent tool — agent-driven local issue management.
2//!
3//! Implements [`oxi_agent::AgentTool`] against the [`FileIssueStore`]. One
4//! tool with an `action` discriminator (matches the pattern used by the
5//! `github` tool, see `oxi-agent/src/tools/github.rs`).
6//!
7//! Actions: `list`, `read`, `create`, `update`, `start`, `release`, `close`,
8//! `link_session`. See [`AgentTool::parameters_schema`] for the exact JSON
9//! schema each action accepts.
10//!
11//! Design notes:
12//! - The tool holds an `Arc<FileIssueStore>` so it can be cheaply cloned when
13//!   the tool is registered in the live `ToolRegistry` (mirrors how
14//!   `McpTool`/`WasmTool` hold their managers).
15//! - The "current session id" used for assignment / session-linking is taken
16//!   from `ToolContext.session_id`. The agent loop fills this in from the
17//!   active session.
18
19use 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/// The `issue` tool. One registration, multiple actions.
28#[derive(Debug, Clone)]
29pub struct IssueTool {
30    store: Arc<FileIssueStore>,
31}
32
33impl IssueTool {
34    /// Construct a new `issue` tool backed by `store`.
35    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
305// ── Formatting helpers (shared with the CLI subcommand in Phase 1.5) ─────
306
307/// Render an issue as a single summary line (id, status, priority, lock,
308/// title, labels, assignee). Used by both the agent tool and the CLI.
309pub 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
333/// Render a full issue view (summary line + meta + body). Used by both the
334/// agent tool and the CLI.
335pub 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
366// ── Param parsers (centralized so each action doesn't repeat) ────────────
367
368fn 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}