1use 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 pub head: String,
43 pub base: String,
45 pub state: PrState,
46 pub labels: Vec<String>,
47 pub reviewers: Vec<String>,
48 pub comments: Vec<PrComment>,
49 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
78pub 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
117pub 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
127pub 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
152pub 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
167pub 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
176pub 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
194pub 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 assert!(merge_pr(&dir, 1).is_err());
259
260 let _ = fs::remove_dir_all(&dir);
261 }
262}