1use std::sync::Arc;
20
21use crate::{AgentTool, AgentToolResult, ToolContext};
22use async_trait::async_trait;
23use serde_json::{Value, json};
24
25use crate::issues::{FileIssueStore, Issue, IssueError, IssueFilter, IssuePatch, 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 `.oxicode/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 For `update`: every field is optional — omit to keep, provide to replace; \
59 `labels: []` clears all labels (omit to keep). Prefer the dedicated \
60 `close`/`reopen`/`start`/`release` actions over `update { status }`. \
61 To resume a closed issue, call `reopen`, then `start`. Concurrent edits \
62 are auto-reconciled (up to 4 retries), so a stale `content_hash` from \
63 an earlier `read` still succeeds."
64 }
65
66 fn parameters_schema(&self) -> Value {
67 json!({
68 "type": "object",
69 "properties": {
70 "action": {
71 "type": "string",
72 "enum": ["list", "read", "create", "update", "reopen", "start", "release", "close", "link_session"],
73 "description": "Issue operation. For `update`, every field is optional — omit to keep, provide to replace. Concurrent edits are auto-reconciled (up to 4 retries)."
74 },
75 "id": {"type": "integer", "description": "Issue id (for read/update/reopen/start/release/close/link_session)."},
76 "title": {"type": "string", "description": "create: required. update: replaces the title. Max 512 chars."},
77 "body": {"type": "string", "description": "create: optional (defaults empty). update: replaces the body. Max 256 KiB."},
78 "priority": {"type": "string", "enum": ["low", "medium", "high", "critical"], "description": "create/update: new priority. list: filter to this priority."},
79 "labels": {"type": "array", "items": {"type": "string"}, "description": "create/update: REPLACES labels entirely. Omit to keep; pass [] to clear all. Max 32 labels, 64 chars each."},
80 "status": {"type": "string", "enum": ["open", "closed"], "description": "list: filter by status. update: new status (prefer the `close`/`reopen` actions for clarity)."},
81 "label": {"type": "string", "description": "list: filter to issues with this label."},
82 "text": {"type": "string", "description": "list: case-insensitive substring filter on the title."},
83 "content_hash": {"type": "string", "description": "Hash from the last `read`. ADVISORY: the tool auto re-reads and retries on conflict, so a stale hash still succeeds."},
84 "github": {"type": "object", "readOnly": true, "description": "READ-ONLY. Populated by GitHub sync (Phase 6); cannot be set via this tool."}
85 },
86 "required": ["action"]
87 })
88 }
89
90 fn essential(&self) -> bool {
91 false
92 }
93
94 async fn execute(
95 &self,
96 _tool_call_id: &str,
97 params: Value,
98 _signal: Option<tokio::sync::oneshot::Receiver<()>>,
99 ctx: &ToolContext,
100 ) -> Result<AgentToolResult, String> {
101 let action = match params.get("action").and_then(|v| v.as_str()) {
102 Some(a) => a.to_string(),
103 None => return Ok(AgentToolResult::error("missing required field: action")),
104 };
105
106 if let Err(e) = validate_size(¶ms, &action) {
108 return Ok(AgentToolResult::error(e));
109 }
110
111 let session = ctx.session_id.clone().unwrap_or_default();
112 let result: Result<String, String> = match action.as_str() {
113 "list" => self.list(params),
114 "read" => self.read(params).await,
115 "create" => self.create(params, &session).await,
116 "update" => self.update(params, &session).await,
117 "start" => self.start(params, &session).await,
118 "release" => self.release(params, &session).await,
119 "close" => self.close(params, &session).await,
120 "reopen" => self.reopen(params).await,
121 "link_session" => self.link_session(params, &session).await,
122 other => Err(format!("unknown action: {other}")),
123 };
124
125 Ok(match result {
126 Ok(text) => AgentToolResult::success(text),
127 Err(e) => AgentToolResult::error(e),
128 })
129 }
130}
131
132impl IssueTool {
133 fn list(&self, params: Value) -> Result<String, String> {
134 let status = parse_status_opt(params.get("status"))?;
135 let priority = parse_priority_opt(params.get("priority"))?;
136 let label = params
137 .get("label")
138 .and_then(|v| v.as_str())
139 .map(String::from);
140 let text = params
141 .get("text")
142 .and_then(|v| v.as_str())
143 .map(String::from);
144 let filter = IssueFilter {
145 status,
146 priority,
147 label,
148 assigned_to_session: None,
149 text,
150 };
151 let issues = self.store.list(&filter).map_err(|e| e.to_string())?;
152 if issues.is_empty() {
153 return Ok("no issues match the filter".to_string());
154 }
155 Ok(issues
156 .iter()
157 .map(format_issue_line)
158 .collect::<Vec<_>>()
159 .join("\n"))
160 }
161
162 async fn read(&self, params: Value) -> Result<String, String> {
163 let id = require_u32(params.get("id"), "id")?;
164 self.store
165 .read(id)
166 .map(|(issue, hash)| format_issue_full(&issue, &hash))
167 .map_err(|e| e.to_string())
168 }
169
170 async fn create(&self, params: Value, session: &str) -> Result<String, String> {
171 let title = require_string(params.get("title"), "title")?;
172 let body = params
173 .get("body")
174 .and_then(|v| v.as_str())
175 .unwrap_or("")
176 .to_string();
177 let priority = parse_priority_opt(params.get("priority"))?.unwrap_or(Priority::Medium);
178 let labels = parse_labels(params.get("labels"))?;
179 let session_opt = if session.is_empty() {
180 None
181 } else {
182 Some(session)
183 };
184 let issue = self
185 .store
186 .create(title, body, priority, labels, session_opt)
187 .map_err(|e| e.to_string())?;
188 Ok(format!(
189 "created issue #{}: {}",
190 issue.meta.id, issue.meta.title
191 ))
192 }
193
194 async fn update(&self, params: Value, session: &str) -> Result<String, String> {
195 let id = require_u32(params.get("id"), "id")?;
196 let agent_hash = hash_param(params.get("content_hash"));
197 let patch = IssuePatch {
200 title: params
201 .get("title")
202 .and_then(|v| v.as_str())
203 .map(String::from),
204 body: params
205 .get("body")
206 .and_then(|v| v.as_str())
207 .map(String::from),
208 status: parse_status_opt(params.get("status"))?,
209 priority: parse_priority_opt(params.get("priority"))?,
210 labels: params
211 .get("labels")
212 .map(|v| parse_labels(Some(v)))
213 .transpose()?,
214 };
215 let caller = if session.is_empty() {
216 None
217 } else {
218 Some(session.to_string())
219 };
220 let store = self.store.clone();
221 cas_retry(&store, id, agent_hash, |hash| {
222 let store = store.clone();
223 let patch = patch.clone();
224 let caller = caller.clone();
225 async move { store.apply_patch(id, patch, caller, hash).await }
226 })
227 .await
228 .map(|issue| format!("updated issue #{}", issue.meta.id))
229 .map_err(|e| e.to_string())
230 }
231
232 async fn start(&self, params: Value, session: &str) -> Result<String, String> {
233 let id = require_u32(params.get("id"), "id")?;
234 if session.is_empty() {
235 return Err("cannot start: no active session id in context".to_string());
236 }
237 let agent_hash = hash_param(params.get("content_hash"));
238 let store = self.store.clone();
239 let session = session.to_string();
240 cas_retry(&store, id, agent_hash, |hash| {
241 let store = store.clone();
242 let session = session.clone();
243 async move { store.start(id, &session, hash).await }
244 })
245 .await
246 .map(|issue| format!("assigned issue #{} to session {}", issue.meta.id, session))
247 .map_err(|e| e.to_string())
248 }
249
250 async fn release(&self, params: Value, session: &str) -> Result<String, String> {
251 let id = require_u32(params.get("id"), "id")?;
252 if session.is_empty() {
253 return Err("cannot release: no active session id in context".to_string());
254 }
255 let agent_hash = hash_param(params.get("content_hash"));
256 let store = self.store.clone();
257 let session = session.to_string();
258 cas_retry(&store, id, agent_hash, |hash| {
259 let store = store.clone();
260 let session = session.clone();
261 async move { store.release(id, &session, hash).await }
262 })
263 .await
264 .map(|_| format!("released issue #{id}"))
265 .map_err(|e| e.to_string())
266 }
267
268 async fn close(&self, params: Value, session: &str) -> Result<String, String> {
269 let id = require_u32(params.get("id"), "id")?;
270 if session.is_empty() {
271 return Err("cannot close: no active session id in context".to_string());
272 }
273 let agent_hash = hash_param(params.get("content_hash"));
274 let store = self.store.clone();
275 let session = session.to_string();
276 cas_retry(&store, id, agent_hash, |hash| {
277 let store = store.clone();
278 let session = session.clone();
279 async move { store.close(id, &session, hash).await }
280 })
281 .await
282 .map(|issue| format!("closed issue #{}: {}", issue.meta.id, issue.meta.title))
283 .map_err(|e| e.to_string())
284 }
285
286 async fn reopen(&self, params: Value) -> Result<String, String> {
287 let id = require_u32(params.get("id"), "id")?;
288 let agent_hash = hash_param(params.get("content_hash"));
289 let store = self.store.clone();
290 cas_retry(&store, id, agent_hash, |hash| {
291 let store = store.clone();
292 async move { store.reopen(id, hash).await }
293 })
294 .await
295 .map(|issue| format!("reopened issue #{}: {}", issue.meta.id, issue.meta.title))
296 .map_err(|e| e.to_string())
297 }
298
299 async fn link_session(&self, params: Value, session: &str) -> Result<String, String> {
300 let id = require_u32(params.get("id"), "id")?;
301 if session.is_empty() {
302 return Err("cannot link_session: no active session id in context".to_string());
303 }
304 let agent_hash = hash_param(params.get("content_hash"));
305 let store = self.store.clone();
306 let session = session.to_string();
307 cas_retry(&store, id, agent_hash, |hash| {
308 let store = store.clone();
309 let session = session.clone();
310 async move { store.link_session(id, &session, hash).await }
311 })
312 .await
313 .map(|_| format!("linked session to issue #{id}"))
314 .map_err(|e| e.to_string())
315 }
316}
317
318pub fn format_issue_line(i: &Issue) -> String {
323 let lock = if i.meta.assigned_to.is_some() {
324 "▣"
325 } else {
326 " "
327 };
328 let assignee = i
329 .meta
330 .assigned_to
331 .as_ref()
332 .map(|a| format!(" (assigned: {})", short_session(&a.session)))
333 .unwrap_or_default();
334 format!(
335 "#{:<4} [{}] {:8} {}{} {}{}",
336 i.meta.id,
337 i.meta.status,
338 i.meta.priority,
339 lock,
340 i.meta.title,
341 i.meta.labels.join(","),
342 assignee,
343 )
344}
345
346pub fn format_issue_full(i: &Issue, hash: &str) -> String {
349 let mut s = format_issue_line(i);
350 s.push('\n');
351 s.push_str(&format!(" id: {}\n", i.meta.id));
352 s.push_str(&format!(" created: {}\n", i.meta.created_at));
353 s.push_str(&format!(" updated: {}\n", i.meta.updated_at));
354 if let Some(c) = i.meta.closed_at {
355 s.push_str(&format!(" closed: {}\n", c));
356 }
357 s.push_str(&format!(" sessions: {:?}\n", i.meta.sessions));
358 if let Some(a) = &i.meta.assigned_to {
359 s.push_str(&format!(
360 " assigned: {} (since {})\n",
361 short_session(&a.session),
362 a.acquired_at
363 ));
364 }
365 s.push_str(&format!(" content_hash: {}\n", hash));
366 s.push('\n');
367 s.push_str(&i.body);
368 s
369}
370
371fn short_session(s: &str) -> String {
372 if s.len() <= 8 {
373 s.to_string()
374 } else {
375 format!("{}…", &s[..8])
376 }
377}
378
379const MAX_CAS_ATTEMPTS: u32 = 4;
388
389pub async fn cas_retry<T, F, Fut>(
396 store: &FileIssueStore,
397 id: u32,
398 agent_hash: Option<String>,
399 mut op: F,
400) -> Result<T, IssueError>
401where
402 F: FnMut(Option<String>) -> Fut,
403 Fut: Future<Output = Result<T, IssueError>> + Send,
404 T: Send,
405{
406 let mut hash = agent_hash;
407 for attempt in 0..MAX_CAS_ATTEMPTS {
408 match op(hash.clone()).await {
409 Ok(v) => return Ok(v),
410 Err(IssueError::Conflict { .. }) if attempt + 1 < MAX_CAS_ATTEMPTS => {
411 tracing::debug!(
412 id,
413 attempt = attempt + 1,
414 "issue CAS conflict, re-reading fresh hash"
415 );
416 hash = store.read(id).ok().map(|(_, h)| h);
417 continue;
418 }
419 Err(e) => return Err(e),
420 }
421 }
422 Err(IssueError::Conflict { id })
423}
424
425fn hash_param(v: Option<&Value>) -> Option<String> {
427 v.and_then(|x| x.as_str())
428 .filter(|s| !s.is_empty())
429 .map(String::from)
430}
431
432const MAX_TITLE_LEN: usize = 512;
436const MAX_BODY_LEN: usize = 256 * 1024;
438const MAX_LABELS: usize = 32;
440const MAX_LABEL_LEN: usize = 64;
442
443fn validate_size(params: &Value, action: &str) -> Result<(), String> {
450 if !matches!(action, "create" | "update") {
451 return Ok(());
452 }
453 if let Some(t) = params.get("title").and_then(|v| v.as_str())
454 && t.chars().count() > MAX_TITLE_LEN
455 {
456 return Err(format!("title too long (max {MAX_TITLE_LEN} chars)"));
457 }
458 if let Some(b) = params.get("body").and_then(|v| v.as_str())
459 && b.len() > MAX_BODY_LEN
460 {
461 return Err(format!("body too large (max {MAX_BODY_LEN} bytes)"));
462 }
463 if let Some(l) = params.get("labels").and_then(|v| v.as_array()) {
464 if l.len() > MAX_LABELS {
465 return Err(format!("too many labels (max {MAX_LABELS})"));
466 }
467 for item in l {
468 if item.as_str().map(|s| s.chars().count()).unwrap_or(0) > MAX_LABEL_LEN {
469 return Err(format!("label too long (max {MAX_LABEL_LEN} chars)"));
470 }
471 }
472 }
473 Ok(())
474}
475
476fn require_string(v: Option<&Value>, name: &str) -> Result<String, String> {
477 v.and_then(|x| x.as_str())
478 .map(String::from)
479 .ok_or_else(|| format!("missing required field: {name}"))
480}
481
482fn require_u32(v: Option<&Value>, name: &str) -> Result<u32, String> {
483 v.and_then(|x| x.as_u64())
484 .and_then(|n| u32::try_from(n).ok())
485 .ok_or_else(|| format!("missing or invalid field: {name}"))
486}
487
488fn parse_status_opt(v: Option<&Value>) -> Result<Option<Status>, String> {
489 let Some(v) = v else { return Ok(None) };
490 let s = v
491 .as_str()
492 .ok_or_else(|| "status must be a string".to_string())?;
493 match s {
494 "open" => Ok(Some(Status::Open)),
495 "closed" => Ok(Some(Status::Closed)),
496 other => Err(format!("invalid status: {other}")),
497 }
498}
499
500fn parse_priority_opt(v: Option<&Value>) -> Result<Option<Priority>, String> {
501 let Some(v) = v else { return Ok(None) };
502 let s = v
503 .as_str()
504 .ok_or_else(|| "priority must be a string".to_string())?;
505 match s {
506 "low" => Ok(Some(Priority::Low)),
507 "medium" => Ok(Some(Priority::Medium)),
508 "high" => Ok(Some(Priority::High)),
509 "critical" => Ok(Some(Priority::Critical)),
510 other => Err(format!("invalid priority: {other}")),
511 }
512}
513
514fn parse_labels(v: Option<&Value>) -> Result<Vec<String>, String> {
515 let Some(v) = v else { return Ok(vec![]) };
516 let arr = v
517 .as_array()
518 .ok_or_else(|| "labels must be an array of strings".to_string())?;
519 let mut out = Vec::with_capacity(arr.len());
520 for item in arr {
521 let s = item
522 .as_str()
523 .ok_or_else(|| "labels must be an array of strings".to_string())?;
524 out.push(s.to_string());
525 }
526 Ok(out)
527}
528
529#[cfg(test)]
530mod tests {
531 use super::*;
534
535 fn tmp_store() -> (tempfile::TempDir, FileIssueStore) {
536 let tmp = tempfile::tempdir().unwrap();
537 let dir = tmp.path().join(".oxicode").join("issues");
538 std::fs::create_dir_all(&dir).unwrap();
539 (tmp, FileIssueStore::open(dir).unwrap())
540 }
541
542 #[tokio::test]
543 async fn cas_retry_recovers_from_stale_hash() {
544 let (_tmp, store) = tmp_store();
548 store
549 .create("T".into(), "b".into(), Priority::Low, vec![], None)
550 .unwrap();
551 let id = 1;
552
553 let result: Result<Issue, _> = cas_retry(
554 &store,
555 id,
556 Some("deadbeefdeadbeef".to_string()), |hash| {
558 let store = store.clone();
559 async move {
560 store
561 .apply_patch(
562 id,
563 IssuePatch {
564 title: Some("Patched".into()),
565 ..Default::default()
566 },
567 None,
568 hash,
569 )
570 .await
571 }
572 },
573 )
574 .await;
575 let issue = result.expect("cas_retry should recover from a stale hash");
576 assert_eq!(issue.meta.title, "Patched");
577 }
578
579 #[tokio::test]
580 async fn cas_retry_gives_up_after_bound() {
581 let (_tmp, store) = tmp_store();
584 store
585 .create("T".into(), "b".into(), Priority::Low, vec![], None)
586 .unwrap();
587 let id = 1;
588
589 let result: Result<Issue, _> = cas_retry(&store, id, None, |_hash| async move {
590 Err(IssueError::Conflict { id })
591 })
592 .await;
593 assert!(
594 matches!(result, Err(IssueError::Conflict { id: 1 })),
595 "must give up with Conflict after the bound, got: {result:?}"
596 );
597 }
598
599 #[test]
602 fn validate_size_passes_small_payload() {
603 let p = json!({"title": "ok", "body": "short", "labels": ["a", "b"]});
604 assert!(validate_size(&p, "create").is_ok());
605 assert!(validate_size(&p, "update").is_ok());
606 }
607
608 #[test]
609 fn validate_size_skips_non_text_actions() {
610 let p = json!({"body": "x".repeat(300_000)});
612 assert!(validate_size(&p, "list").is_ok());
613 assert!(validate_size(&p, "start").is_ok());
614 }
615
616 #[test]
617 fn validate_size_rejects_oversize_body() {
618 let p = json!({"body": "x".repeat(MAX_BODY_LEN + 1)});
619 let err = validate_size(&p, "create").unwrap_err();
620 assert!(err.contains("body too large"), "got: {err}");
621 }
622
623 #[test]
624 fn validate_size_rejects_oversize_title() {
625 let p = json!({"title": "x".repeat(MAX_TITLE_LEN + 1)});
626 let err = validate_size(&p, "update").unwrap_err();
627 assert!(err.contains("title too long"), "got: {err}");
628 }
629
630 #[test]
631 fn validate_size_rejects_too_many_labels() {
632 let labels: Vec<&str> = (0..(MAX_LABELS + 1)).map(|_| "l").collect();
633 let p = json!({"labels": labels});
634 let err = validate_size(&p, "create").unwrap_err();
635 assert!(err.contains("too many labels"), "got: {err}");
636 }
637
638 #[test]
639 fn validate_size_rejects_long_label() {
640 let p = json!({"labels": ["x".repeat(MAX_LABEL_LEN + 1)]});
641 let err = validate_size(&p, "create").unwrap_err();
642 assert!(err.contains("label too long"), "got: {err}");
643 }
644}