Skip to main content

lit/commands/
pr.rs

1//! Pull Requests stored as git refs inside the repository
2//!
3//! PRs are stored at .lit/refs/prs/<id>.json and tracked inside the
4//! repository, not in an external service.
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 PrState {
13    Open,
14    Merged,
15    Closed,
16}
17
18impl std::fmt::Display for PrState {
19    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
20        match self {
21            PrState::Open => write!(f, "open"),
22            PrState::Merged => write!(f, "merged"),
23            PrState::Closed => write!(f, "closed"),
24        }
25    }
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct PrComment {
30    pub author: String,
31    pub body: String,
32    pub created: String,
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct PullRequest {
37    pub id: u64,
38    pub title: String,
39    pub body: String,
40    pub author: String,
41    /// Source branch
42    pub head: String,
43    /// Target branch
44    pub base: String,
45    pub state: PrState,
46    pub labels: Vec<String>,
47    pub reviewers: Vec<String>,
48    pub comments: Vec<PrComment>,
49    /// Commit hash at head when PR was created
50    pub head_commit: Option<String>,
51    pub created: String,
52    pub updated: String,
53}
54
55fn prs_dir(repo_root: &Path) -> std::path::PathBuf {
56    repo_root.join(".lit").join("refs").join("prs")
57}
58
59fn next_id(repo_root: &Path) -> Result<u64, LitError> {
60    let dir = prs_dir(repo_root);
61    if !dir.exists() {
62        return Ok(1);
63    }
64    let mut max_id: u64 = 0;
65    for entry in fs::read_dir(&dir).map_err(|e| LitError::io(format!("IO: {}", e)))? {
66        let entry = entry.map_err(|e| LitError::io(format!("IO: {}", e)))?;
67        if let Some(stem) = entry.path().file_stem() {
68            if let Ok(id) = stem.to_string_lossy().parse::<u64>() {
69                if id > max_id {
70                    max_id = id;
71                }
72            }
73        }
74    }
75    Ok(max_id + 1)
76}
77
78/// Create a new pull request
79pub fn create_pr(
80    repo_root: &Path,
81    title: &str,
82    body: &str,
83    author: &str,
84    head: &str,
85    base: &str,
86    labels: Vec<String>,
87) -> Result<PullRequest, LitError> {
88    let dir = prs_dir(repo_root);
89    fs::create_dir_all(&dir)
90        .map_err(|e| LitError::io(format!("Failed to create PRs dir: {}", e)))?;
91
92    let id = next_id(repo_root)?;
93    let now = chrono::Utc::now().to_rfc3339();
94    let pr = PullRequest {
95        id,
96        title: title.to_string(),
97        body: body.to_string(),
98        author: author.to_string(),
99        head: head.to_string(),
100        base: base.to_string(),
101        state: PrState::Open,
102        labels,
103        reviewers: Vec::new(),
104        comments: Vec::new(),
105        head_commit: None,
106        created: now.clone(),
107        updated: now,
108    };
109
110    let path = dir.join(format!("{}.json", id));
111    let json = serde_json::to_string_pretty(&pr)
112        .map_err(|e| LitError::general(format!("Serialize: {}", e)))?;
113    fs::write(&path, json).map_err(|e| LitError::io(format!("Write: {}", e)))?;
114    Ok(pr)
115}
116
117/// Get a PR by ID
118pub fn get_pr(repo_root: &Path, id: u64) -> Result<PullRequest, LitError> {
119    let path = prs_dir(repo_root).join(format!("{}.json", id));
120    if !path.exists() {
121        return Err(LitError::general(format!("PR #{} not found", id)));
122    }
123    let json = fs::read_to_string(&path).map_err(|e| LitError::io(format!("IO: {}", e)))?;
124    serde_json::from_str(&json).map_err(|e| LitError::general(format!("Parse: {}", e)))
125}
126
127/// List all PRs, optionally filtered by state
128pub fn list_prs(repo_root: &Path, state: Option<PrState>) -> Result<Vec<PullRequest>, LitError> {
129    let dir = prs_dir(repo_root);
130    if !dir.exists() {
131        return Ok(Vec::new());
132    }
133
134    let mut prs = Vec::new();
135    for entry in fs::read_dir(&dir).map_err(|e| LitError::io(format!("IO: {}", e)))? {
136        let entry = entry.map_err(|e| LitError::io(format!("IO: {}", e)))?;
137        if entry.path().extension().is_some_and(|e| e == "json") {
138            if let Ok(json) = fs::read_to_string(entry.path()) {
139                if let Ok(pr) = serde_json::from_str::<PullRequest>(&json) {
140                    if state.as_ref().is_none_or(|s| pr.state == *s) {
141                        prs.push(pr);
142                    }
143                }
144            }
145        }
146    }
147
148    prs.sort_by_key(|b| std::cmp::Reverse(b.id));
149    Ok(prs)
150}
151
152/// Merge a PR (mark it as merged)
153pub fn merge_pr(repo_root: &Path, id: u64) -> Result<PullRequest, LitError> {
154    let mut pr = get_pr(repo_root, id)?;
155    if pr.state != PrState::Open {
156        return Err(LitError::general(format!(
157            "PR #{} is not open ({})",
158            id, pr.state
159        )));
160    }
161    pr.state = PrState::Merged;
162    pr.updated = chrono::Utc::now().to_rfc3339();
163    save_pr(repo_root, &pr)?;
164    Ok(pr)
165}
166
167/// Close a PR without merging
168pub fn close_pr(repo_root: &Path, id: u64) -> Result<PullRequest, LitError> {
169    let mut pr = get_pr(repo_root, id)?;
170    pr.state = PrState::Closed;
171    pr.updated = chrono::Utc::now().to_rfc3339();
172    save_pr(repo_root, &pr)?;
173    Ok(pr)
174}
175
176/// Add a comment to a PR
177pub fn comment_pr(
178    repo_root: &Path,
179    id: u64,
180    author: &str,
181    body: &str,
182) -> Result<PullRequest, LitError> {
183    let mut pr = get_pr(repo_root, id)?;
184    pr.comments.push(PrComment {
185        author: author.to_string(),
186        body: body.to_string(),
187        created: chrono::Utc::now().to_rfc3339(),
188    });
189    pr.updated = chrono::Utc::now().to_rfc3339();
190    save_pr(repo_root, &pr)?;
191    Ok(pr)
192}
193
194/// Add a reviewer to a PR
195pub fn add_reviewer(repo_root: &Path, id: u64, reviewer: &str) -> Result<PullRequest, LitError> {
196    let mut pr = get_pr(repo_root, id)?;
197    if !pr.reviewers.contains(&reviewer.to_string()) {
198        pr.reviewers.push(reviewer.to_string());
199        pr.updated = chrono::Utc::now().to_rfc3339();
200        save_pr(repo_root, &pr)?;
201    }
202    Ok(pr)
203}
204
205fn save_pr(repo_root: &Path, pr: &PullRequest) -> Result<(), LitError> {
206    let path = prs_dir(repo_root).join(format!("{}.json", pr.id));
207    let json = serde_json::to_string_pretty(pr)
208        .map_err(|e| LitError::general(format!("Serialize: {}", e)))?;
209    fs::write(&path, json).map_err(|e| LitError::io(format!("Write: {}", e)))?;
210    Ok(())
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216    use std::path::PathBuf;
217    use std::sync::atomic::{AtomicU32, Ordering};
218
219    static COUNTER: AtomicU32 = AtomicU32::new(0);
220
221    fn tmp_dir() -> PathBuf {
222        let n = COUNTER.fetch_add(1, Ordering::SeqCst);
223        let dir = std::env::temp_dir().join(format!("lit_pr_test_{}_{}", std::process::id(), n));
224        fs::create_dir_all(&dir).unwrap();
225        dir
226    }
227
228    #[test]
229    fn test_create_and_list() {
230        let dir = tmp_dir();
231        let pr = create_pr(
232            &dir,
233            "Add DID support",
234            "Implements DIDs",
235            "did:lit:user1",
236            "feature/did",
237            "main",
238            vec!["feature".into()],
239        )
240        .unwrap();
241        assert_eq!(pr.id, 1);
242        assert_eq!(pr.state, PrState::Open);
243
244        let prs = list_prs(&dir, None).unwrap();
245        assert_eq!(prs.len(), 1);
246
247        let _ = fs::remove_dir_all(&dir);
248    }
249
250    #[test]
251    fn test_merge_pr() {
252        let dir = tmp_dir();
253        create_pr(&dir, "Test", "Body", "user1", "feature", "main", vec![]).unwrap();
254        let merged = merge_pr(&dir, 1).unwrap();
255        assert_eq!(merged.state, PrState::Merged);
256
257        // Can't merge twice
258        assert!(merge_pr(&dir, 1).is_err());
259
260        let _ = fs::remove_dir_all(&dir);
261    }
262}