Skip to main content

lit/commands/
issue.rs

1//! Issues stored as git refs inside the repository
2//!
3//! Issues are stored at .lit/refs/issues/<id>.json and tracked inside
4//! the repository itself, not in an external database.
5
6use crate::errors::LitError;
7use serde::{Deserialize, Serialize};
8use std::fs;
9use std::path::Path;
10
11#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
12pub enum IssueState {
13    Open,
14    Closed,
15}
16
17impl std::fmt::Display for IssueState {
18    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
19        match self {
20            IssueState::Open => write!(f, "open"),
21            IssueState::Closed => write!(f, "closed"),
22        }
23    }
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct IssueComment {
28    pub author: String,
29    pub body: String,
30    pub created: String,
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct Issue {
35    pub id: u64,
36    pub title: String,
37    pub body: String,
38    pub author: String,
39    pub state: IssueState,
40    pub labels: Vec<String>,
41    pub comments: Vec<IssueComment>,
42    pub created: String,
43    pub updated: String,
44}
45
46fn issues_dir(repo_root: &Path) -> std::path::PathBuf {
47    repo_root.join(".lit").join("refs").join("issues")
48}
49
50fn next_id(repo_root: &Path) -> Result<u64, LitError> {
51    let dir = issues_dir(repo_root);
52    if !dir.exists() {
53        return Ok(1);
54    }
55    let mut max_id: u64 = 0;
56    for entry in fs::read_dir(&dir).map_err(|e| LitError::io(format!("IO: {}", e)))? {
57        let entry = entry.map_err(|e| LitError::io(format!("IO: {}", e)))?;
58        if let Some(stem) = entry.path().file_stem() {
59            if let Ok(id) = stem.to_string_lossy().parse::<u64>() {
60                if id > max_id {
61                    max_id = id;
62                }
63            }
64        }
65    }
66    Ok(max_id + 1)
67}
68
69/// Create a new issue
70pub fn create_issue(
71    repo_root: &Path,
72    title: &str,
73    body: &str,
74    author: &str,
75    labels: Vec<String>,
76) -> Result<Issue, LitError> {
77    let dir = issues_dir(repo_root);
78    fs::create_dir_all(&dir)
79        .map_err(|e| LitError::io(format!("Failed to create issues dir: {}", e)))?;
80
81    let id = next_id(repo_root)?;
82    let now = chrono::Utc::now().to_rfc3339();
83    let issue = Issue {
84        id,
85        title: title.to_string(),
86        body: body.to_string(),
87        author: author.to_string(),
88        state: IssueState::Open,
89        labels,
90        comments: Vec::new(),
91        created: now.clone(),
92        updated: now,
93    };
94
95    let path = dir.join(format!("{}.json", id));
96    let json = serde_json::to_string_pretty(&issue)
97        .map_err(|e| LitError::general(format!("Serialize: {}", e)))?;
98    fs::write(&path, json).map_err(|e| LitError::io(format!("Write: {}", e)))?;
99    Ok(issue)
100}
101
102/// Get an issue by ID
103pub fn get_issue(repo_root: &Path, id: u64) -> Result<Issue, LitError> {
104    let path = issues_dir(repo_root).join(format!("{}.json", id));
105    if !path.exists() {
106        return Err(LitError::general(format!("Issue #{} not found", id)));
107    }
108    let json = fs::read_to_string(&path).map_err(|e| LitError::io(format!("IO: {}", e)))?;
109    serde_json::from_str(&json).map_err(|e| LitError::general(format!("Parse: {}", e)))
110}
111
112/// List all issues, optionally filtered by state
113pub fn list_issues(repo_root: &Path, state: Option<IssueState>) -> Result<Vec<Issue>, LitError> {
114    let dir = issues_dir(repo_root);
115    if !dir.exists() {
116        return Ok(Vec::new());
117    }
118
119    let mut issues = Vec::new();
120    for entry in fs::read_dir(&dir).map_err(|e| LitError::io(format!("IO: {}", e)))? {
121        let entry = entry.map_err(|e| LitError::io(format!("IO: {}", e)))?;
122        if entry.path().extension().is_some_and(|e| e == "json") {
123            if let Ok(json) = fs::read_to_string(entry.path()) {
124                if let Ok(issue) = serde_json::from_str::<Issue>(&json) {
125                    if state.as_ref().is_none_or(|s| issue.state == *s) {
126                        issues.push(issue);
127                    }
128                }
129            }
130        }
131    }
132
133    issues.sort_by_key(|b| std::cmp::Reverse(b.id));
134    Ok(issues)
135}
136
137/// Close an issue
138pub fn close_issue(repo_root: &Path, id: u64) -> Result<Issue, LitError> {
139    let mut issue = get_issue(repo_root, id)?;
140    issue.state = IssueState::Closed;
141    issue.updated = chrono::Utc::now().to_rfc3339();
142    save_issue(repo_root, &issue)?;
143    Ok(issue)
144}
145
146/// Add a comment to an issue
147pub fn comment_issue(
148    repo_root: &Path,
149    id: u64,
150    author: &str,
151    body: &str,
152) -> Result<Issue, LitError> {
153    let mut issue = get_issue(repo_root, id)?;
154    issue.comments.push(IssueComment {
155        author: author.to_string(),
156        body: body.to_string(),
157        created: chrono::Utc::now().to_rfc3339(),
158    });
159    issue.updated = chrono::Utc::now().to_rfc3339();
160    save_issue(repo_root, &issue)?;
161    Ok(issue)
162}
163
164fn save_issue(repo_root: &Path, issue: &Issue) -> Result<(), LitError> {
165    let path = issues_dir(repo_root).join(format!("{}.json", issue.id));
166    let json = serde_json::to_string_pretty(issue)
167        .map_err(|e| LitError::general(format!("Serialize: {}", e)))?;
168    fs::write(&path, json).map_err(|e| LitError::io(format!("Write: {}", e)))?;
169    Ok(())
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175    use std::path::PathBuf;
176    use std::sync::atomic::{AtomicU32, Ordering};
177
178    static COUNTER: AtomicU32 = AtomicU32::new(0);
179
180    fn tmp_dir() -> PathBuf {
181        let n = COUNTER.fetch_add(1, Ordering::SeqCst);
182        let dir = std::env::temp_dir().join(format!("lit_issue_test_{}_{}", std::process::id(), n));
183        fs::create_dir_all(&dir).unwrap();
184        dir
185    }
186
187    #[test]
188    fn test_create_and_list_issues() {
189        let dir = tmp_dir();
190        let issue = create_issue(
191            &dir,
192            "Bug: crash on merge",
193            "Merging fails with panic",
194            "did:lit:user1",
195            vec!["bug".into()],
196        )
197        .unwrap();
198        assert_eq!(issue.id, 1);
199        assert_eq!(issue.state, IssueState::Open);
200
201        let issues = list_issues(&dir, None).unwrap();
202        assert_eq!(issues.len(), 1);
203
204        let _ = fs::remove_dir_all(&dir);
205    }
206
207    #[test]
208    fn test_close_issue() {
209        let dir = tmp_dir();
210        create_issue(&dir, "Test", "Body", "user1", vec![]).unwrap();
211        let closed = close_issue(&dir, 1).unwrap();
212        assert_eq!(closed.state, IssueState::Closed);
213
214        let open = list_issues(&dir, Some(IssueState::Open)).unwrap();
215        assert_eq!(open.len(), 0);
216
217        let _ = fs::remove_dir_all(&dir);
218    }
219
220    #[test]
221    fn test_comment_issue() {
222        let dir = tmp_dir();
223        create_issue(&dir, "Test", "Body", "user1", vec![]).unwrap();
224        let issue = comment_issue(&dir, 1, "user2", "This needs fixing").unwrap();
225        assert_eq!(issue.comments.len(), 1);
226        assert_eq!(issue.comments[0].author, "user2");
227
228        let _ = fs::remove_dir_all(&dir);
229    }
230}