1use std::fs::{self, OpenOptions};
53use std::hash::{Hash, Hasher};
54use std::io;
55use std::os::unix::io::AsRawFd;
56use std::path::{Path, PathBuf};
57use std::sync::Arc;
58
59use anyhow::{Context, Result};
60use chrono::{DateTime, Utc};
61use parking_lot::RwLock;
62use serde::{Deserialize, Serialize};
63
64#[derive(Debug, thiserror::Error)]
74pub enum IssueError {
75 #[error("issue #{id} was modified since last read; re-read and retry")]
78 Conflict { id: u32 },
79
80 #[error("issue #{id} is currently being worked on by session {owner}")]
82 Assigned {
83 id: u32,
84 owner: String,
85 acquired_at: DateTime<Utc>,
86 },
87
88 #[error("issue #{id} is not assigned to session {caller}; run `start` first")]
90 NotAssigned { id: u32, caller: String },
91
92 #[error("issue #{id} not found")]
94 NotFound { id: u32 },
95
96 #[error(transparent)]
97 Io(#[from] io::Error),
98
99 #[error(transparent)]
100 Other(#[from] anyhow::Error),
101}
102
103#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
109#[serde(rename_all = "lowercase")]
110pub enum Status {
111 #[default]
112 Open,
113 Closed,
114}
115
116impl std::fmt::Display for Status {
117 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
118 match self {
119 Self::Open => write!(f, "open"),
120 Self::Closed => write!(f, "closed"),
121 }
122 }
123}
124
125#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, Serialize, Deserialize)]
127#[serde(rename_all = "lowercase")]
128pub enum Priority {
129 Low,
130 #[default]
131 Medium,
132 High,
133 Critical,
134}
135
136impl std::fmt::Display for Priority {
137 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
138 match self {
139 Self::Low => write!(f, "low"),
140 Self::Medium => write!(f, "medium"),
141 Self::High => write!(f, "high"),
142 Self::Critical => write!(f, "critical"),
143 }
144 }
145}
146
147#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
153pub struct Assignment {
154 pub session: String,
156 pub acquired_at: DateTime<Utc>,
159}
160
161#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
163pub struct GithubRef {
164 pub repo: String,
165 pub number: u64,
166 pub url: String,
167}
168
169#[derive(Debug, Clone, Serialize, Deserialize)]
171pub struct IssueMeta {
172 pub id: u32,
173 pub title: String,
174 #[serde(default)]
175 pub status: Status,
176 #[serde(default)]
177 pub priority: Priority,
178 #[serde(default)]
179 pub labels: Vec<String>,
180 #[serde(default, skip_serializing_if = "Option::is_none")]
181 pub assignee: Option<String>,
182 pub created_at: DateTime<Utc>,
183 pub updated_at: DateTime<Utc>,
184 #[serde(default, skip_serializing_if = "Option::is_none")]
185 pub closed_at: Option<DateTime<Utc>>,
186 #[serde(default)]
188 pub sessions: Vec<String>,
189 #[serde(default, skip_serializing_if = "Option::is_none")]
191 pub assigned_to: Option<Assignment>,
192 #[serde(default, skip_serializing_if = "Option::is_none")]
194 pub github: Option<GithubRef>,
195}
196
197#[derive(Debug, Clone)]
199pub struct Issue {
200 pub meta: IssueMeta,
201 pub body: String,
203 pub path: Option<PathBuf>,
205}
206
207impl Issue {
208 pub fn list_badge(&self) -> String {
210 let lock = if self.meta.assigned_to.is_some() {
211 "🔒 "
212 } else {
213 ""
214 };
215 format!("{}{}", lock, self.meta.status)
216 }
217}
218
219#[derive(Debug, Clone, Default)]
232pub struct IssuePatch {
233 pub title: Option<String>,
235 pub body: Option<String>,
237 pub status: Option<Status>,
240 pub priority: Option<Priority>,
242 pub labels: Option<Vec<String>>,
244}
245
246use super::fs_util::atomic_write;
251
252const FRONTMATTER_DELIM: &str = "---";
253
254pub fn parse_issue(raw: &str, path: Option<PathBuf>) -> Result<Issue> {
268 let raw = raw.strip_prefix('\u{feff}').unwrap_or(raw);
269
270 let after_open = match raw.strip_prefix(FRONTMATTER_DELIM) {
274 Some(rest) => rest,
275 None => {
276 return Ok(Issue {
277 meta: empty_meta(),
278 body: raw.to_string(),
279 path,
280 });
281 }
282 };
283
284 let mut yaml = String::new();
287 let mut body = String::new();
288 let mut closed = false;
289 for line in after_open.split_inclusive('\n') {
290 if !closed && line.trim_end() == FRONTMATTER_DELIM {
291 closed = true;
292 continue;
293 }
294 if !closed {
295 yaml.push_str(line);
296 } else {
297 body.push_str(line);
298 }
299 }
300
301 let meta: IssueMeta =
302 serde_yaml::from_str(&yaml).context("failed to parse issue frontmatter")?;
303 Ok(Issue { meta, body, path })
304}
305
306pub fn serialize_issue(issue: &Issue) -> Result<String> {
308 let yaml = serde_yaml::to_string(&issue.meta).context("failed to serialize frontmatter")?;
309 let body = if issue.body.is_empty() {
312 String::new()
313 } else if issue.body.ends_with('\n') {
314 issue.body.clone()
315 } else {
316 format!("{}\n", issue.body)
317 };
318 Ok(format!(
319 "{open}\n{yaml}{close}\n{body}",
320 open = FRONTMATTER_DELIM,
321 close = FRONTMATTER_DELIM
322 ))
323}
324
325pub fn content_hash(raw: &str) -> String {
328 let mut hasher = std::collections::hash_map::DefaultHasher::new();
329 raw.hash(&mut hasher);
330 format!("{:016x}", hasher.finish())
331}
332
333pub fn issues_dir(start: &Path) -> PathBuf {
343 let mut dir = start.to_path_buf();
344 loop {
345 if dir.join(".oxi").is_dir() {
346 return dir.join(".oxi").join("issues");
347 }
348 if !dir.pop() {
349 break;
350 }
351 }
352 start.join(".oxi").join("issues")
353}
354
355pub fn issue_filename(id: u32, title: &str) -> String {
357 let slug = slugify(title);
358 if slug.is_empty() {
359 format!("{:04}.md", id)
360 } else {
361 format!("{:04}-{}.md", id, slug)
362 }
363}
364
365fn empty_meta() -> IssueMeta {
367 let now = Utc::now();
368 IssueMeta {
369 id: 0,
370 title: String::new(),
371 status: Status::default(),
372 priority: Priority::default(),
373 labels: vec![],
374 assignee: None,
375 created_at: now,
376 updated_at: now,
377 closed_at: None,
378 sessions: vec![],
379 assigned_to: None,
380 github: None,
381 }
382}
383
384fn slugify(s: &str) -> String {
386 let mut out = String::new();
387 let mut prev_dash = false;
388 for c in s.chars() {
389 if c.is_ascii_alphanumeric() {
390 out.push(c.to_ascii_lowercase());
391 prev_dash = false;
392 } else if !prev_dash {
393 out.push('-');
394 prev_dash = true;
395 }
396 }
397 out.trim_matches('-').to_string()
398}
399
400pub mod liveness {
411 use super::*;
412
413 pub const TUI_OWNERSHIP_ID: &str = "tui";
423
424 pub fn alive_path(issues_dir: &Path, session_id: &str) -> PathBuf {
426 issues_dir.join(".alive").join(session_id)
427 }
428
429 pub fn acquire(issues_dir: &Path, session_id: &str) -> io::Result<AliveGuard> {
436 let dir = issues_dir.join(".alive");
437 fs::create_dir_all(&dir)?;
438 let path = dir.join(session_id);
439 let file = OpenOptions::new()
440 .write(true)
441 .create(true)
442 .truncate(false)
443 .open(&path)?;
444 let fd = file.as_raw_fd();
445 try_flock_exclusive(fd)?;
447 Ok(AliveGuard { _file: file, path })
448 }
449
450 pub fn is_session_alive(issues_dir: &Path, session_id: &str) -> bool {
453 let path = alive_path(issues_dir, session_id);
454 if !path.exists() {
455 return false;
456 }
457 let Ok(file) = OpenOptions::new().read(true).write(true).open(&path) else {
460 return false;
461 };
462 let fd = file.as_raw_fd();
463 probe_flock_shared(fd).is_err()
465 }
466
467 fn try_flock_exclusive(fd: i32) -> io::Result<()> {
481 let rc = unsafe { libc::flock(fd, libc::LOCK_EX | libc::LOCK_NB) };
484 if rc == 0 {
485 Ok(())
486 } else {
487 Err(io::Error::last_os_error())
488 }
489 }
490
491 fn probe_flock_shared(fd: i32) -> io::Result<()> {
498 let rc = unsafe { libc::flock(fd, libc::LOCK_SH | libc::LOCK_NB) };
500 if rc == 0 {
501 unsafe { libc::flock(fd, libc::LOCK_UN) };
503 Ok(())
504 } else {
505 Err(io::Error::last_os_error())
506 }
507 }
508
509 pub const ORPHAN_AGE_SECS: u64 = 3600; pub fn reap_orphans(issues_dir: &Path) -> io::Result<usize> {
531 let dir = issues_dir.join(".alive");
532 let rd = match fs::read_dir(&dir) {
533 Ok(rd) => rd,
534 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(0),
535 Err(e) => return Err(e),
536 };
537 let now = std::time::SystemTime::now();
538 let mut removed = 0;
539 for entry in rd.flatten() {
540 let sid = entry.file_name();
541 let sid = sid.to_string_lossy();
542 if is_session_alive(issues_dir, &sid) {
543 continue; }
545 let mtime = entry.metadata().and_then(|m| m.modified()).unwrap_or(now);
546 let age = now.duration_since(mtime).map(|d| d.as_secs()).unwrap_or(0);
547 if age < ORPHAN_AGE_SECS {
548 continue; }
550 if fs::remove_file(entry.path()).is_ok() {
551 removed += 1;
552 }
553 }
554 Ok(removed)
555 }
556
557 #[derive(Debug)]
559 pub struct AliveGuard {
560 _file: fs::File,
561 path: PathBuf,
562 }
563
564 impl AliveGuard {
565 pub fn path(&self) -> &Path {
566 &self.path
567 }
568 }
569
570 impl Drop for AliveGuard {
571 fn drop(&mut self) {
572 let _ = fs::remove_file(&self.path);
574 }
575 }
576
577 #[cfg(test)]
578 mod tests {
579 use super::*;
580
581 #[test]
582 fn acquire_then_alive() {
583 let tmp = tempfile::tempdir().unwrap();
584 let dir = tmp.path().to_path_buf();
585 let sid = "s1";
586 let _g = acquire(&dir, sid).unwrap();
587 assert!(is_session_alive(&dir, sid));
588 drop(_g);
589 assert!(!is_session_alive(&dir, sid));
590 }
591
592 #[test]
593 fn second_acquire_fails_while_held() {
594 let tmp = tempfile::tempdir().unwrap();
595 let dir = tmp.path().to_path_buf();
596 let sid = "s2";
597 let g = acquire(&dir, sid).unwrap();
598 let second = acquire(&dir, sid);
599 assert!(second.is_err(), "second acquire should fail while held");
600 assert!(is_session_alive(&dir, sid));
601 drop(g);
602 assert!(acquire(&dir, sid).is_ok(), "after drop, acquire succeeds");
603 }
604
605 fn backdate(path: &std::path::Path, secs: u64) {
609 use std::fs::FileTimes;
610 let then = std::time::SystemTime::now() - std::time::Duration::from_secs(secs);
611 let f = std::fs::File::open(path)
612 .or_else(|_| {
613 std::fs::OpenOptions::new()
614 .read(true)
615 .write(true)
616 .create(true)
617 .truncate(false) .open(path)
619 })
620 .unwrap();
621 f.set_times(FileTimes::new().set_modified(then)).unwrap();
622 }
623
624 #[test]
625 fn reap_idempotent() {
626 let tmp = tempfile::tempdir().unwrap();
627 let dir = tmp.path().to_path_buf();
628 assert_eq!(reap_orphans(&dir).unwrap(), 0);
630 fs::create_dir_all(dir.join(".alive")).unwrap();
631 assert_eq!(reap_orphans(&dir).unwrap(), 0);
633 assert_eq!(reap_orphans(&dir).unwrap(), 0);
634 }
635
636 #[test]
637 fn reap_skips_recent_dead_files() {
638 let tmp = tempfile::tempdir().unwrap();
641 let dir = tmp.path().to_path_buf();
642 fs::create_dir_all(dir.join(".alive")).unwrap();
643 let recent = dir.join(".alive").join("dead-recent");
644 fs::write(&recent, b"").unwrap();
645 assert_eq!(reap_orphans(&dir).unwrap(), 0);
647 assert!(
648 recent.exists(),
649 "recent dead orphan must be preserved by the age gate"
650 );
651 }
652
653 #[test]
654 fn reap_removes_old_dead_and_keeps_alive() {
655 let tmp = tempfile::tempdir().unwrap();
656 let dir = tmp.path().to_path_buf();
657 let _g_live = acquire(&dir, "alive-session").unwrap();
659 fs::create_dir_all(dir.join(".alive")).unwrap();
661 let old = dir.join(".alive").join("dead-old");
662 fs::write(&old, b"").unwrap();
663 backdate(&old, ORPHAN_AGE_SECS + 60);
664
665 let removed = reap_orphans(&dir).unwrap();
666 assert_eq!(removed, 1, "only the old dead orphan should be reaped");
667 assert!(!old.exists(), "old dead orphan must be removed");
668 assert!(
670 is_session_alive(&dir, "alive-session"),
671 "live lock must survive reap"
672 );
673 }
674 }
675}
676
677#[derive(Debug, Default, Clone)]
685struct Cache {
686 open_count: usize,
688 latest_open_title: Option<String>,
690 locked_open_count: usize,
693 top_priority: Option<Priority>,
696 top_free_priority: Option<Priority>,
701 dir_mtime: Option<std::time::SystemTime>,
702}
703
704#[derive(Debug, Clone)]
707pub struct IssueSummary {
708 pub open_count: usize,
709 pub locked_open_count: usize,
710 pub top_priority: Option<Priority>,
711 pub top_free_priority: Option<Priority>,
714 pub latest_open_title: Option<String>,
715}
716
717impl IssueSummary {
718 pub fn is_empty(&self) -> bool {
719 self.open_count == 0
720 }
721}
722
723struct Inner {
725 issues_dir: PathBuf,
726 cache: Cache,
727}
728
729impl Cache {
730 fn empty() -> Self {
731 Self {
732 open_count: 0,
733 latest_open_title: None,
734 locked_open_count: 0,
735 top_priority: None,
736 top_free_priority: None,
737 dir_mtime: None,
738 }
739 }
740}
741
742impl std::fmt::Debug for Inner {
743 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
744 f.debug_struct("Inner")
745 .field("issues_dir", &self.issues_dir)
746 .finish()
747 }
748}
749
750#[derive(Clone, Debug)]
757pub struct FileIssueStore {
758 inner: Arc<RwLock<Inner>>,
759}
760
761impl FileIssueStore {
762 pub fn open(issues_dir: PathBuf) -> Result<Self> {
764 if let Err(e) = liveness::reap_orphans(&issues_dir) {
768 tracing::warn!(error = %e, "issue liveness reap failed (non-fatal)");
769 }
770 Ok(Self {
771 inner: Arc::new(RwLock::new(Inner {
772 issues_dir,
773 cache: Cache::default(),
774 })),
775 })
776 }
777
778 pub fn open_from_cwd(start: &Path) -> Result<Self> {
780 Self::open(issues_dir(start))
781 }
782
783 pub fn issues_dir(&self) -> PathBuf {
785 self.inner.read().issues_dir.clone()
786 }
787
788 pub fn open_count(&self) -> usize {
791 self.refresh_if_stale();
792 self.inner.read().cache.open_count
793 }
794
795 pub fn latest_open_title(&self) -> Option<String> {
799 self.refresh_if_stale();
800 self.inner.read().cache.latest_open_title.clone()
801 }
802
803 pub fn summary(&self) -> IssueSummary {
806 self.refresh_if_stale();
807 let g = self.inner.read();
808 IssueSummary {
809 open_count: g.cache.open_count,
810 locked_open_count: g.cache.locked_open_count,
811 top_priority: g.cache.top_priority,
812 top_free_priority: g.cache.top_free_priority,
813 latest_open_title: g.cache.latest_open_title.clone(),
814 }
815 }
816
817 pub fn top_free_priority(&self) -> Option<Priority> {
823 self.refresh_if_stale();
824 self.inner.read().cache.top_free_priority
825 }
826
827 pub fn has_any(&self) -> bool {
830 self.refresh_if_stale();
831 let dir = self.inner.read().issues_dir.clone();
832 fs::read_dir(&dir)
833 .map(|rd| {
834 rd.filter_map(|e| e.ok())
835 .any(|e| e.path().extension().and_then(|x| x.to_str()) == Some("md"))
836 })
837 .unwrap_or(false)
838 }
839
840 fn refresh_if_stale(&self) {
842 let dir = self.inner.read().issues_dir.clone();
843 let cur_dir_mtime = fs::metadata(&dir).and_then(|m| m.modified()).ok();
844 let needs = {
845 let g = self.inner.read();
846 match (g.cache.dir_mtime, cur_dir_mtime) {
847 (None, _) => true, (Some(_), None) => false, (Some(cached), Some(cur)) => cached != cur,
850 }
851 };
852 if !needs {
853 return;
854 }
855 let mut open_count = 0;
857 let mut locked_open_count = 0;
858 let mut top_priority: Option<Priority> = None;
859 let mut latest_open_title: Option<String> = None;
860 let mut latest_open_updated: Option<chrono::DateTime<chrono::Utc>> = None;
861 let mut top_free_priority: Option<Priority> = None;
862 if let Ok(rd) = fs::read_dir(&dir) {
863 for entry in rd.flatten() {
864 let p = entry.path();
865 if p.extension().and_then(|x| x.to_str()) != Some("md") {
866 continue;
867 }
868 if let Ok(raw) = fs::read_to_string(&p)
871 && let Ok(issue) = parse_issue(&raw, None)
872 && issue.meta.status == Status::Open
873 {
874 open_count += 1;
875 if issue.meta.assigned_to.is_some() {
876 locked_open_count += 1;
877 }
878 top_priority = Some(match top_priority {
880 Some(existing) => existing.max(issue.meta.priority),
881 None => issue.meta.priority,
882 });
883 if issue.meta.updated_at
884 > latest_open_updated.unwrap_or(chrono::DateTime::<chrono::Utc>::MIN_UTC)
885 {
886 latest_open_updated = Some(issue.meta.updated_at);
887 latest_open_title = Some(issue.meta.title);
888 }
889 if issue.meta.assigned_to.is_none() {
891 top_free_priority = Some(match top_free_priority {
892 Some(cur) if cur >= issue.meta.priority => cur,
893 _ => issue.meta.priority,
894 });
895 }
896 }
897 }
898 }
899 let mut g = self.inner.write();
900 g.cache = Cache {
901 open_count,
902 latest_open_title,
903 locked_open_count,
904 top_priority,
905 top_free_priority,
906 dir_mtime: cur_dir_mtime,
907 };
908 }
909
910 pub fn invalidate(&self) {
912 self.inner.write().cache = Cache::default();
913 }
914
915 pub fn list(&self, filter: &IssueFilter) -> Result<Vec<Issue>> {
919 self.refresh_if_stale();
920 let dir = self.inner.read().issues_dir.clone();
921 let mut out = Vec::new();
922 if let Ok(rd) = fs::read_dir(&dir) {
923 for entry in rd.flatten() {
924 let p = entry.path();
925 if p.extension().and_then(|x| x.to_str()) != Some("md") {
926 continue;
927 }
928 let raw = fs::read_to_string(&p)?;
929 let issue = parse_issue(&raw, Some(p.clone()))?;
930 if filter.matches(&issue) {
931 out.push(issue);
932 }
933 }
934 }
935 out.sort_by_key(|i| std::cmp::Reverse(i.meta.updated_at));
936 Ok(out)
937 }
938
939 pub fn read(&self, id: u32) -> Result<(Issue, String)> {
942 let path = self.path_for_id(id)?;
943 let raw = fs::read_to_string(&path)
944 .with_context(|| format!("issue #{} not found at {}", id, path.display()))?;
945 let issue = parse_issue(&raw, Some(path))?;
946 Ok((issue, content_hash(&raw)))
947 }
948
949 pub fn next_id(&self) -> Result<u32> {
958 let dir = self.inner.read().issues_dir.clone();
959 fs::create_dir_all(&dir)?;
960 let mut max = 0u32;
961 if let Ok(rd) = fs::read_dir(&dir) {
962 for entry in rd.flatten() {
963 let name = entry.file_name();
964 let name = name.to_string_lossy();
965 let num_str = name.split('-').next().unwrap_or(&name);
966 if let Ok(n) = num_str.trim_end_matches(".md").parse::<u32>() {
967 max = max.max(n);
968 }
969 }
970 }
971 Ok(max + 1)
972 }
973
974 pub fn create(
976 &self,
977 title: String,
978 body: String,
979 priority: Priority,
980 labels: Vec<String>,
981 caller_session: Option<&str>,
982 ) -> Result<Issue> {
983 let id = self.next_id()?;
984 let now = Utc::now();
985 let sessions = caller_session
986 .map(|s| vec![s.to_string()])
987 .unwrap_or_default();
988 let issue = Issue {
989 meta: IssueMeta {
990 id,
991 title,
992 status: Status::Open,
993 priority,
994 labels,
995 assignee: None,
996 created_at: now,
997 updated_at: now,
998 closed_at: None,
999 sessions,
1000 assigned_to: None,
1001 github: None,
1002 },
1003 body,
1004 path: None,
1005 };
1006 for _ in 0..4 {
1008 let path = self
1009 .issues_dir()
1010 .join(issue_filename(id, &issue.meta.title));
1011 if path.exists() {
1012 continue;
1014 }
1015 let content = serialize_issue(&issue)?;
1016 atomic_write(&path, &content)?;
1017 self.invalidate();
1018 let mut saved = issue.clone();
1019 saved.path = Some(path);
1020 return Ok(saved);
1021 }
1022 anyhow::bail!("could not allocate a free issue id after retries");
1023 }
1024
1025 pub async fn update<F>(
1034 &self,
1035 id: u32,
1036 expected_hash: Option<String>,
1037 mutator: F,
1038 ) -> std::result::Result<Issue, IssueError>
1039 where
1040 F: FnOnce(Issue) -> std::result::Result<Issue, IssueError> + Send + 'static,
1041 {
1042 let path = self.path_for_id(id).map_err(IssueError::Other)?;
1043 let path_for_closure = path.clone();
1044 let store = self.clone();
1045 oxi_agent::tools::file_mutation_queue::global_mutation_queue()
1047 .with_queue(&path, move || async move {
1048 let path = path_for_closure;
1049 let raw = fs::read_to_string(&path)?;
1050 if let Some(expected) = expected_hash.as_deref()
1051 && content_hash(&raw) != expected
1052 {
1053 return Err(IssueError::Conflict { id });
1054 }
1055 let before = parse_issue(&raw, Some(path.clone())).map_err(IssueError::Other)?;
1056 let before_updated_at = before.meta.updated_at;
1057 let before_bytes = serialize_issue(&before).map_err(IssueError::Other)?;
1058 let after = mutator(before)?;
1059
1060 let mut probe = after.clone();
1067 probe.meta.updated_at = before_updated_at;
1068 let probe_bytes = serialize_issue(&probe).map_err(IssueError::Other)?;
1069 if probe_bytes == before_bytes {
1070 return Ok(after.with_path(path));
1071 }
1072
1073 let mut final_issue = after;
1074 final_issue.meta.updated_at = Utc::now();
1075 let content = serialize_issue(&final_issue).map_err(IssueError::Other)?;
1076 atomic_write(&path, &content)?;
1077 store.invalidate();
1078 Ok(final_issue.with_path(path))
1079 })
1080 .await
1081 }
1082
1083 pub async fn close(
1085 &self,
1086 id: u32,
1087 caller: &str,
1088 expected_hash: Option<String>,
1089 ) -> std::result::Result<Issue, IssueError> {
1090 let now = Utc::now();
1091 let caller = caller.to_string();
1092 self.update(id, expected_hash, move |mut issue| {
1093 require_owner(&issue, id, &caller)?;
1094 issue.meta.status = Status::Closed;
1095 issue.meta.closed_at = Some(now);
1096 issue.meta.assigned_to = None; Ok(issue)
1098 })
1099 .await
1100 }
1101
1102 pub async fn reopen(
1108 &self,
1109 id: u32,
1110 expected_hash: Option<String>,
1111 ) -> std::result::Result<Issue, IssueError> {
1112 self.update(id, expected_hash, move |mut issue| {
1113 if issue.meta.status == Status::Open {
1114 return Ok(issue);
1117 }
1118 issue.meta.status = Status::Open;
1119 issue.meta.closed_at = None;
1120 issue.meta.assigned_to = None;
1121 Ok(issue)
1122 })
1123 .await
1124 }
1125
1126 pub async fn start(
1132 &self,
1133 id: u32,
1134 caller: &str,
1135 expected_hash: Option<String>,
1136 ) -> std::result::Result<Issue, IssueError> {
1137 let issues_dir = self.issues_dir();
1138 let caller_owned = caller.to_string();
1139 self.update(id, expected_hash, move |mut issue| {
1140 if let Some(ref a) = issue.meta.assigned_to {
1141 if a.session == caller_owned {
1142 return Ok(issue);
1144 }
1145 if liveness::is_session_alive(&issues_dir, &a.session) {
1146 return Err(IssueError::Assigned {
1147 id,
1148 owner: a.session.clone(),
1149 acquired_at: a.acquired_at,
1150 });
1151 }
1152 }
1154 issue.meta.assigned_to = Some(Assignment {
1155 session: caller_owned.clone(),
1156 acquired_at: Utc::now(),
1157 });
1158 if !issue.meta.sessions.contains(&caller_owned) {
1160 issue.meta.sessions.push(caller_owned.clone());
1161 }
1162 Ok(issue)
1163 })
1164 .await
1165 }
1166
1167 pub async fn release(
1169 &self,
1170 id: u32,
1171 caller: &str,
1172 expected_hash: Option<String>,
1173 ) -> std::result::Result<Issue, IssueError> {
1174 let caller = caller.to_string();
1175 self.update(id, expected_hash, move |mut issue| {
1176 require_owner(&issue, id, &caller)?;
1177 issue.meta.assigned_to = None;
1178 Ok(issue)
1179 })
1180 .await
1181 }
1182
1183 pub async fn link_session(
1185 &self,
1186 id: u32,
1187 session: &str,
1188 expected_hash: Option<String>,
1189 ) -> std::result::Result<Issue, IssueError> {
1190 let session = session.to_string();
1191 self.update(id, expected_hash, move |mut issue| {
1192 if !issue.meta.sessions.contains(&session) {
1193 issue.meta.sessions.push(session);
1194 }
1195 Ok(issue)
1196 })
1197 .await
1198 }
1199
1200 pub async fn apply_patch(
1213 &self,
1214 id: u32,
1215 patch: IssuePatch,
1216 caller: Option<String>,
1217 expected_hash: Option<String>,
1218 ) -> std::result::Result<Issue, IssueError> {
1219 self.update(id, expected_hash, move |mut issue| {
1220 if let Some(caller) = caller.as_deref()
1221 && let Some(ref a) = issue.meta.assigned_to
1222 && !a.session.is_empty()
1223 && a.session != caller
1224 {
1225 return Err(IssueError::NotAssigned {
1226 id,
1227 caller: caller.to_string(),
1228 });
1229 }
1230 if let Some(t) = patch.title {
1231 issue.meta.title = t;
1232 }
1233 if let Some(b) = patch.body {
1234 issue.body = b;
1235 }
1236 if let Some(s) = patch.status {
1237 issue.meta.status = s;
1238 issue.meta.closed_at = match s {
1239 Status::Closed => Some(Utc::now()),
1240 Status::Open => None, };
1242 }
1243 if let Some(p) = patch.priority {
1244 issue.meta.priority = p;
1245 }
1246 if let Some(l) = patch.labels {
1247 issue.meta.labels = l;
1248 }
1249 Ok(issue)
1250 })
1251 .await
1252 }
1253
1254 fn path_for_id(&self, id: u32) -> Result<PathBuf> {
1257 let dir = self.inner.read().issues_dir.clone();
1258 if let Ok(rd) = fs::read_dir(&dir) {
1260 for entry in rd.flatten() {
1261 let name = entry.file_name();
1262 let name = name.to_string_lossy();
1263 let num_str = name.split('-').next().unwrap_or(&name);
1264 if num_str.trim_end_matches(".md").parse::<u32>().ok() == Some(id) {
1265 return Ok(entry.path());
1266 }
1267 }
1268 }
1269 Err(anyhow::anyhow!(IssueError::NotFound { id }))
1270 }
1271}
1272
1273trait WithPath {
1275 fn with_path(self, path: PathBuf) -> Self;
1276}
1277
1278impl WithPath for Issue {
1279 fn with_path(mut self, path: PathBuf) -> Self {
1280 self.path = Some(path);
1281 self
1282 }
1283}
1284
1285fn require_owner(issue: &Issue, id: u32, caller: &str) -> std::result::Result<(), IssueError> {
1287 match &issue.meta.assigned_to {
1288 Some(a) if a.session == caller => Ok(()),
1289 _ => Err(IssueError::NotAssigned {
1290 id,
1291 caller: caller.to_string(),
1292 }),
1293 }
1294}
1295
1296#[derive(Debug, Clone, Default)]
1302pub struct IssueFilter {
1303 pub status: Option<Status>,
1304 pub priority: Option<Priority>,
1305 pub label: Option<String>,
1306 pub assigned_to_session: Option<String>,
1307 pub text: Option<String>,
1309}
1310
1311impl IssueFilter {
1312 fn matches(&self, issue: &Issue) -> bool {
1313 if let Some(s) = self.status
1314 && issue.meta.status != s
1315 {
1316 return false;
1317 }
1318 if let Some(p) = self.priority
1319 && issue.meta.priority != p
1320 {
1321 return false;
1322 }
1323 if let Some(ref label) = self.label
1324 && !issue.meta.labels.iter().any(|l| l == label)
1325 {
1326 return false;
1327 }
1328 if let Some(ref session) = self.assigned_to_session {
1329 let mine = issue
1330 .meta
1331 .assigned_to
1332 .as_ref()
1333 .map(|a| &a.session == session)
1334 .unwrap_or(false);
1335 if !mine {
1336 return false;
1337 }
1338 }
1339 if let Some(ref text) = self.text
1340 && !issue
1341 .meta
1342 .title
1343 .to_lowercase()
1344 .contains(&text.to_lowercase())
1345 {
1346 return false;
1347 }
1348 true
1349 }
1350}
1351
1352#[cfg(test)]
1357mod tests {
1358 use super::*;
1359
1360 fn sample_meta(id: u32, title: &str, priority: Priority) -> IssueMeta {
1361 let now = Utc::now();
1362 IssueMeta {
1363 id,
1364 title: title.into(),
1365 status: Status::Open,
1366 priority,
1367 labels: vec![],
1368 assignee: None,
1369 created_at: now,
1370 updated_at: now,
1371 closed_at: None,
1372 sessions: vec![],
1373 assigned_to: None,
1374 github: None,
1375 }
1376 }
1377
1378 fn tmp_store() -> (tempfile::TempDir, FileIssueStore) {
1379 let tmp = tempfile::tempdir().unwrap();
1380 let dir = tmp.path().join(".oxi").join("issues");
1381 fs::create_dir_all(&dir).unwrap();
1382 let store = FileIssueStore::open(dir).unwrap();
1383 (tmp, store)
1384 }
1385
1386 #[test]
1387 fn roundtrip_serialization() {
1388 let issue = Issue {
1389 meta: sample_meta(1, "Test", Priority::High),
1390 body: "## Body\n\nHello.".into(),
1391 path: None,
1392 };
1393 let s = serialize_issue(&issue).unwrap();
1394 assert!(s.starts_with("---\n"));
1395 let parsed = parse_issue(&s, None).unwrap();
1396 assert_eq!(parsed.meta.id, 1);
1397 assert_eq!(parsed.meta.title, "Test");
1398 assert_eq!(parsed.meta.priority, Priority::High);
1399 assert!(parsed.body.contains("Hello."));
1400 }
1401
1402 #[tokio::test]
1403 async fn create_read_list() {
1404 let (_tmp, store) = tmp_store();
1405 let created = store
1406 .create(
1407 "Fix bug".into(),
1408 "body".into(),
1409 Priority::High,
1410 vec![],
1411 None,
1412 )
1413 .unwrap();
1414 assert_eq!(created.meta.id, 1);
1415
1416 let (read, hash) = store.read(1).unwrap();
1417 assert_eq!(read.meta.title, "Fix bug");
1418 assert!(!hash.is_empty());
1419
1420 let list = store.list(&IssueFilter::default()).unwrap();
1421 assert_eq!(list.len(), 1);
1422 }
1423
1424 #[tokio::test]
1425 async fn content_hash_detects_conflict() {
1426 let (_tmp, store) = tmp_store();
1427 store
1428 .create("Orig".into(), "b".into(), Priority::Low, vec![], None)
1429 .unwrap();
1430 let (_, hash) = store.read(1).unwrap();
1431
1432 let wrong = Some("deadbeefdeadbeef".to_string());
1434 let err = store
1435 .update(1, wrong, |_| {
1436 Ok(Issue {
1437 meta: sample_meta(1, "x", Priority::Low),
1438 body: "x".into(),
1439 path: None,
1440 })
1441 })
1442 .await
1443 .unwrap_err();
1444 assert!(matches!(err, IssueError::Conflict { id: 1 }));
1445
1446 let _ok = store
1448 .update(1, Some(hash), |mut i| {
1449 i.meta.title = "Updated".into();
1450 Ok(i)
1451 })
1452 .await
1453 .unwrap();
1454 let (read, _) = store.read(1).unwrap();
1455 assert_eq!(read.meta.title, "Updated");
1456 }
1457
1458 #[tokio::test]
1459 async fn start_rejects_live_owner() {
1460 let (_tmp, store) = tmp_store();
1461 store
1462 .create("T".into(), "b".into(), Priority::Low, vec![], None)
1463 .unwrap();
1464 let issues_dir = store.issues_dir();
1465 let _guard_a = liveness::acquire(&issues_dir, "sessionA").unwrap();
1467 let (_, hash) = store.read(1).unwrap();
1469 store.start(1, "sessionA", Some(hash)).await.unwrap();
1470
1471 let (_, hash2) = store.read(1).unwrap();
1473 let err = store.start(1, "sessionB", Some(hash2)).await.unwrap_err();
1474 assert!(matches!(err, IssueError::Assigned { owner, .. } if owner == "sessionA"));
1475 }
1476
1477 #[tokio::test]
1478 async fn start_reclaims_dead_owner() {
1479 let (_tmp, store) = tmp_store();
1480 store
1481 .create("T".into(), "b".into(), Priority::Low, vec![], None)
1482 .unwrap();
1483 let issues_dir = store.issues_dir();
1484
1485 {
1487 let _g = liveness::acquire(&issues_dir, "sessionA").unwrap();
1488 let (_, h) = store.read(1).unwrap();
1489 store.start(1, "sessionA", Some(h)).await.unwrap();
1490 } let (_, hash) = store.read(1).unwrap();
1493 let reclaimed = store.start(1, "sessionB", Some(hash)).await.unwrap();
1494 assert_eq!(
1495 reclaimed.meta.assigned_to.as_ref().unwrap().session,
1496 "sessionB"
1497 );
1498 }
1499
1500 #[tokio::test]
1501 async fn close_requires_owner() {
1502 let (_tmp, store) = tmp_store();
1503 store
1504 .create("T".into(), "b".into(), Priority::Low, vec![], None)
1505 .unwrap();
1506 let (_, hash) = store.read(1).unwrap();
1507 store.start(1, "sessionA", Some(hash)).await.unwrap();
1508
1509 let (_, hash2) = store.read(1).unwrap();
1511 let err = store.close(1, "sessionB", Some(hash2)).await.unwrap_err();
1512 assert!(matches!(err, IssueError::NotAssigned { .. }));
1513
1514 let (_, hash3) = store.read(1).unwrap();
1516 let closed = store.close(1, "sessionA", Some(hash3)).await.unwrap();
1517 assert_eq!(closed.meta.status, Status::Closed);
1518 assert!(closed.meta.assigned_to.is_none());
1519 }
1520
1521 #[tokio::test]
1522 async fn reopen_flips_closed_to_open() {
1523 let (_tmp, store) = tmp_store();
1524 let issues_dir = store.issues_dir();
1525 let _guard = crate::store::issues::liveness::acquire(&issues_dir, "tui").unwrap();
1526 store
1527 .create("T".into(), "b".into(), Priority::Low, vec![], None)
1528 .unwrap();
1529 let (_, h) = store.read(1).unwrap();
1531 store.start(1, "tui", Some(h)).await.unwrap();
1532 let (_, h) = store.read(1).unwrap();
1533 store.close(1, "tui", Some(h)).await.unwrap();
1534 let (_, h) = store.read(1).unwrap();
1536 let reopened = store.reopen(1, Some(h)).await.unwrap();
1537 assert_eq!(reopened.meta.status, Status::Open);
1538 assert!(reopened.meta.closed_at.is_none());
1539 assert!(reopened.meta.assigned_to.is_none());
1540 }
1541
1542 #[tokio::test]
1543 async fn reopen_is_idempotent_on_already_open() {
1544 let (_tmp, store) = tmp_store();
1545 store
1546 .create("T".into(), "b".into(), Priority::Low, vec![], None)
1547 .unwrap();
1548 let (_, h) = store.read(1).unwrap();
1549 let reopened = store.reopen(1, Some(h)).await.unwrap();
1551 assert_eq!(reopened.meta.status, Status::Open);
1552 assert!(reopened.meta.closed_at.is_none());
1553 }
1554
1555 #[test]
1556 fn slugify_basic() {
1557 assert_eq!(slugify("Fix Login Bug!"), "fix-login-bug");
1558 assert_eq!(slugify(" spaces "), "spaces");
1559 assert_eq!(slugify("a__b"), "a-b");
1560 assert_eq!(slugify(""), "");
1561 }
1562
1563 #[test]
1564 fn issue_filename_format() {
1565 assert_eq!(issue_filename(12, "Fix Login"), "0012-fix-login.md");
1566 assert_eq!(issue_filename(1, ""), "0001.md");
1567 }
1568
1569 #[tokio::test]
1570 async fn open_count_caches() {
1571 let (_tmp, store) = tmp_store();
1572 assert_eq!(store.open_count(), 0);
1573 store
1574 .create("A".into(), "b".into(), Priority::Low, vec![], None)
1575 .unwrap();
1576 store
1577 .create("B".into(), "b".into(), Priority::Low, vec![], None)
1578 .unwrap();
1579 assert_eq!(store.open_count(), 2);
1580
1581 let issues_dir = store.issues_dir();
1583 let _guard = liveness::acquire(&issues_dir, "sessionA").unwrap();
1584 let (_, h) = store.read(1).unwrap();
1585 store.start(1, "sessionA", Some(h)).await.unwrap();
1586 let (_, h) = store.read(1).unwrap();
1587 store.close(1, "sessionA", Some(h)).await.unwrap();
1588 store.invalidate();
1589 assert_eq!(store.open_count(), 1);
1590 }
1591
1592 #[tokio::test]
1593 async fn summary_reflects_lock_and_priority() {
1594 let (_tmp, store) = tmp_store();
1595 let issues_dir = store.issues_dir();
1596 let _guard = liveness::acquire(&issues_dir, "sessionA").unwrap();
1597 store
1599 .create("Lowly".into(), "".into(), Priority::Low, vec![], None)
1600 .unwrap();
1601 store
1602 .create("Crit".into(), "".into(), Priority::Critical, vec![], None)
1603 .unwrap();
1604 store
1606 .create("Closed".into(), "".into(), Priority::Medium, vec![], None)
1607 .unwrap();
1608 let (_, h) = store.read(3).unwrap();
1609 store.start(3, "sessionA", Some(h)).await.unwrap();
1610 let (_, h) = store.read(3).unwrap();
1611 store.close(3, "sessionA", Some(h)).await.unwrap();
1612 let (_, h) = store.read(1).unwrap();
1614 store.start(1, "sessionA", Some(h)).await.unwrap();
1615 store.invalidate();
1616
1617 let s = store.summary();
1618 assert_eq!(s.open_count, 2);
1619 assert_eq!(s.locked_open_count, 1);
1620 assert_eq!(s.top_priority, Some(Priority::Critical));
1621 assert!(s.latest_open_title.is_some());
1622 assert!(!s.is_empty());
1623 }
1624
1625 #[tokio::test]
1626 async fn summary_empty_when_no_issues() {
1627 let (_tmp, store) = tmp_store();
1628 let s = store.summary();
1629 assert_eq!(s.open_count, 0);
1630 assert_eq!(s.locked_open_count, 0);
1631 assert!(s.top_priority.is_none());
1632 assert!(s.latest_open_title.is_none());
1633 assert!(s.is_empty());
1634 }
1635
1636 #[tokio::test]
1637 async fn latest_open_title_caches_and_handles_cjk() {
1638 let (_tmp, store) = tmp_store();
1639 assert!(store.latest_open_title().is_none());
1641
1642 let cjk_title =
1647 "버그 수정: 한글 제목도 정상이어야 합니다 — 멀티바이트 인코딩 안전성".to_string();
1648 let cjk_body =
1649 "요약\n\n이 이슈는 한글 본문을 포함합니다. 본문에는 영문과 한글이 섞여 있습니다. "
1650 .repeat(4);
1651 let created = store
1652 .create(cjk_title.clone(), cjk_body, Priority::High, vec![], None)
1653 .unwrap();
1654 assert_eq!(created.meta.title, cjk_title);
1655
1656 let title = store.latest_open_title();
1658 assert_eq!(title.as_deref(), Some(cjk_title.as_str()));
1659
1660 let (read_back, _hash) = store.read(created.meta.id).unwrap();
1662 assert!(read_back.body.contains("한글"));
1663 }
1664
1665 #[tokio::test]
1675 async fn start_with_distinct_live_owners_collides() {
1676 let (_tmp, store) = tmp_store();
1681 let issues_dir = store.issues_dir();
1682 store
1683 .create("T".into(), "b".into(), Priority::Low, vec![], None)
1684 .unwrap();
1685
1686 let _guard_a = liveness::acquire(&issues_dir, "proc-A").unwrap();
1688 let (_, h) = store.read(1).unwrap();
1689 store.start(1, "proc-A", Some(h)).await.unwrap();
1690
1691 let _guard_b = liveness::acquire(&issues_dir, "proc-B").unwrap();
1693 let (_, h2) = store.read(1).unwrap();
1694 let err = store.start(1, "proc-B", Some(h2)).await.unwrap_err();
1695 assert!(
1696 matches!(err, IssueError::Assigned { ref owner, .. } if owner == "proc-A"),
1697 "a second distinct live owner must be rejected, got: {err:?}"
1698 );
1699 }
1700
1701 #[tokio::test]
1702 async fn empty_session_assignment_is_immediately_reclaimable_documentation() {
1703 let (_tmp, store) = tmp_store();
1712 store
1713 .create("T".into(), "b".into(), Priority::Low, vec![], None)
1714 .unwrap();
1715 let issues_dir = store.issues_dir();
1716
1717 let (_, h) = store.read(1).unwrap();
1719 store.start(1, "", Some(h)).await.unwrap();
1720
1721 assert!(
1723 !liveness::is_session_alive(&issues_dir, ""),
1724 "no flock can be held under the empty string"
1725 );
1726
1727 let _guard_c = liveness::acquire(&issues_dir, "proc-C").unwrap();
1731 let (_, h2) = store.read(1).unwrap();
1732 let reclaimed = store.start(1, "proc-C", Some(h2)).await.unwrap();
1733 assert_eq!(
1734 reclaimed.meta.assigned_to.as_ref().unwrap().session,
1735 "proc-C",
1736 "empty-string assignment is reclaimable — this is the #13 bug shape"
1737 );
1738 }
1739
1740 #[tokio::test]
1743 async fn reopen_clears_closed_at() {
1744 let (_tmp, store) = tmp_store();
1747 store
1748 .create("T".into(), "b".into(), Priority::Low, vec![], None)
1749 .unwrap();
1750 let (_, h) = store.read(1).unwrap();
1751 store.start(1, "proc-X", Some(h)).await.unwrap();
1752 let (_, h) = store.read(1).unwrap();
1753 store.close(1, "proc-X", Some(h)).await.unwrap();
1754 let (closed, _) = store.read(1).unwrap();
1755 assert_eq!(closed.meta.status, Status::Closed);
1756 assert!(closed.meta.closed_at.is_some());
1757
1758 let (_, h) = store.read(1).unwrap();
1759 store.reopen(1, Some(h)).await.unwrap();
1760 let (reopened, _) = store.read(1).unwrap();
1761 assert_eq!(reopened.meta.status, Status::Open);
1762 assert!(
1763 reopened.meta.closed_at.is_none(),
1764 "reopen must clear closed_at (#4)"
1765 );
1766 }
1767
1768 #[tokio::test]
1769 async fn apply_patch_status_open_clears_closed_at() {
1770 let (_tmp, store) = tmp_store();
1772 store
1773 .create("T".into(), "b".into(), Priority::Low, vec![], None)
1774 .unwrap();
1775 let (_, h) = store.read(1).unwrap();
1776 store.start(1, "proc-X", Some(h)).await.unwrap();
1777 let (_, h) = store.read(1).unwrap();
1778 store.close(1, "proc-X", Some(h)).await.unwrap();
1779
1780 let (_, h) = store.read(1).unwrap();
1781 store
1782 .apply_patch(
1783 1,
1784 IssuePatch {
1785 status: Some(Status::Open),
1786 ..Default::default()
1787 },
1788 None,
1789 Some(h),
1790 )
1791 .await
1792 .unwrap();
1793 let (after, _) = store.read(1).unwrap();
1794 assert_eq!(after.meta.status, Status::Open);
1795 assert!(
1796 after.meta.closed_at.is_none(),
1797 "apply_patch status=Open must clear closed_at (#4)"
1798 );
1799 }
1800
1801 #[tokio::test]
1802 async fn noop_update_does_not_bump_timestamp() {
1803 let (_tmp, store) = tmp_store();
1806 store
1807 .create("T".into(), "b".into(), Priority::Low, vec![], None)
1808 .unwrap();
1809 let (before, _) = store.read(1).unwrap();
1810 let ts_before = before.meta.updated_at;
1811
1812 let (_, h) = store.read(1).unwrap();
1814 store
1815 .apply_patch(1, IssuePatch::default(), None, Some(h))
1816 .await
1817 .unwrap();
1818 let (after, _) = store.read(1).unwrap();
1819 assert_eq!(
1820 after.meta.updated_at, ts_before,
1821 "no-op update must not bump updated_at (#12)"
1822 );
1823
1824 std::thread::sleep(std::time::Duration::from_millis(5));
1826 let (_, h2) = store.read(1).unwrap();
1827 store
1828 .apply_patch(
1829 1,
1830 IssuePatch {
1831 title: Some("New".into()),
1832 ..Default::default()
1833 },
1834 None,
1835 Some(h2),
1836 )
1837 .await
1838 .unwrap();
1839 let (after2, _) = store.read(1).unwrap();
1840 assert_ne!(
1841 after2.meta.updated_at, ts_before,
1842 "real update must bump updated_at"
1843 );
1844 assert_eq!(after2.meta.title, "New");
1845 }
1846
1847 #[tokio::test]
1848 async fn apply_patch_labels_clear_vs_keep() {
1849 let (_tmp, store) = tmp_store();
1852 store
1853 .create(
1854 "T".into(),
1855 "b".into(),
1856 Priority::Low,
1857 vec!["a".into(), "b".into()],
1858 None,
1859 )
1860 .unwrap();
1861
1862 let (_, h) = store.read(1).unwrap();
1864 store
1865 .apply_patch(
1866 1,
1867 IssuePatch {
1868 priority: Some(Priority::High),
1869 ..Default::default()
1870 },
1871 None,
1872 Some(h),
1873 )
1874 .await
1875 .unwrap();
1876 let (kept, _) = store.read(1).unwrap();
1877 assert_eq!(kept.meta.labels, vec!["a".to_string(), "b".to_string()]);
1878 assert_eq!(kept.meta.priority, Priority::High);
1879
1880 let (_, h) = store.read(1).unwrap();
1882 store
1883 .apply_patch(
1884 1,
1885 IssuePatch {
1886 labels: Some(vec![]),
1887 ..Default::default()
1888 },
1889 None,
1890 Some(h),
1891 )
1892 .await
1893 .unwrap();
1894 let (cleared, _) = store.read(1).unwrap();
1895 assert!(cleared.meta.labels.is_empty(), "Some([]) must clear labels");
1896
1897 let (_, h) = store.read(1).unwrap();
1899 store
1900 .apply_patch(
1901 1,
1902 IssuePatch {
1903 labels: Some(vec!["z".into()]),
1904 ..Default::default()
1905 },
1906 None,
1907 Some(h),
1908 )
1909 .await
1910 .unwrap();
1911 let (replaced, _) = store.read(1).unwrap();
1912 assert_eq!(replaced.meta.labels, vec!["z".to_string()]);
1913 }
1914
1915 #[tokio::test]
1916 async fn apply_patch_enforces_ownership() {
1917 let (_tmp, store) = tmp_store();
1920 store
1921 .create("T".into(), "b".into(), Priority::Low, vec![], None)
1922 .unwrap();
1923 let (_, h) = store.read(1).unwrap();
1924 store.start(1, "proc-A", Some(h)).await.unwrap();
1925
1926 let (_, h) = store.read(1).unwrap();
1928 let err = store
1929 .apply_patch(
1930 1,
1931 IssuePatch {
1932 title: Some("X".into()),
1933 ..Default::default()
1934 },
1935 Some("proc-B".into()),
1936 Some(h),
1937 )
1938 .await
1939 .unwrap_err();
1940 assert!(
1941 matches!(err, IssueError::NotAssigned { ref caller, .. } if caller == "proc-B"),
1942 "non-owner must be rejected, got: {err:?}"
1943 );
1944
1945 let (_, h) = store.read(1).unwrap();
1947 store
1948 .apply_patch(
1949 1,
1950 IssuePatch {
1951 title: Some("X".into()),
1952 ..Default::default()
1953 },
1954 Some("proc-A".into()),
1955 Some(h),
1956 )
1957 .await
1958 .unwrap();
1959 let (patched, _) = store.read(1).unwrap();
1960 assert_eq!(patched.meta.title, "X");
1961 }
1962
1963 #[tokio::test]
1966 async fn top_free_priority_ignores_assigned_and_closed() {
1967 let (_tmp, store) = tmp_store();
1970 store
1971 .create("low".into(), "".into(), Priority::Low, vec![], None)
1972 .unwrap();
1973 store
1974 .create("high".into(), "".into(), Priority::High, vec![], None)
1975 .unwrap();
1976 store
1977 .create(
1978 "critical-assigned".into(),
1979 "".into(),
1980 Priority::Critical,
1981 vec![],
1982 None,
1983 )
1984 .unwrap();
1985 store
1986 .create(
1987 "critical-closed".into(),
1988 "".into(),
1989 Priority::Critical,
1990 vec![],
1991 None,
1992 )
1993 .unwrap();
1994
1995 let (_, h) = store.read(3).unwrap();
1997 store.start(3, "proc", Some(h)).await.unwrap();
1998 let (_, h) = store.read(4).unwrap();
2000 store.start(4, "proc", Some(h)).await.unwrap();
2001 let (_, h) = store.read(4).unwrap();
2002 store.close(4, "proc", Some(h)).await.unwrap();
2003
2004 assert_eq!(store.top_free_priority(), Some(Priority::High));
2006
2007 let (_, h) = store.read(1).unwrap();
2010 store.start(1, "proc", Some(h)).await.unwrap();
2011 let (_, h) = store.read(2).unwrap();
2012 store.start(2, "proc", Some(h)).await.unwrap();
2013 assert_eq!(
2014 store.top_free_priority(),
2015 None,
2016 "no open unassigned issue → None"
2017 );
2018 }
2019}