1use guts_storage::ObjectId;
4use serde::{Deserialize, Serialize};
5use std::time::{SystemTime, UNIX_EPOCH};
6
7use crate::{CollaborationError, Label, Result};
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
11#[serde(rename_all = "lowercase")]
12pub enum PullRequestState {
13 Open,
15 Closed,
17 Merged,
19}
20
21impl std::fmt::Display for PullRequestState {
22 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
23 match self {
24 PullRequestState::Open => write!(f, "open"),
25 PullRequestState::Closed => write!(f, "closed"),
26 PullRequestState::Merged => write!(f, "merged"),
27 }
28 }
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct PullRequest {
34 pub id: u64,
36 pub repo_key: String,
38 pub number: u32,
40 pub title: String,
42 pub description: String,
44 pub author: String,
46 pub state: PullRequestState,
48 pub source_branch: String,
50 pub target_branch: String,
52 pub source_commit: ObjectId,
54 pub target_commit: ObjectId,
56 pub labels: Vec<Label>,
58 pub created_at: u64,
60 pub updated_at: u64,
62 pub merged_at: Option<u64>,
64 pub merged_by: Option<String>,
66}
67
68impl PullRequest {
69 #[allow(clippy::too_many_arguments)]
71 pub fn new(
72 id: u64,
73 repo_key: impl Into<String>,
74 number: u32,
75 title: impl Into<String>,
76 description: impl Into<String>,
77 author: impl Into<String>,
78 source_branch: impl Into<String>,
79 target_branch: impl Into<String>,
80 source_commit: ObjectId,
81 target_commit: ObjectId,
82 ) -> Self {
83 let now = SystemTime::now()
84 .duration_since(UNIX_EPOCH)
85 .unwrap()
86 .as_secs();
87
88 Self {
89 id,
90 repo_key: repo_key.into(),
91 number,
92 title: title.into(),
93 description: description.into(),
94 author: author.into(),
95 state: PullRequestState::Open,
96 source_branch: source_branch.into(),
97 target_branch: target_branch.into(),
98 source_commit,
99 target_commit,
100 labels: Vec::new(),
101 created_at: now,
102 updated_at: now,
103 merged_at: None,
104 merged_by: None,
105 }
106 }
107
108 pub fn is_open(&self) -> bool {
110 self.state == PullRequestState::Open
111 }
112
113 pub fn is_merged(&self) -> bool {
115 self.state == PullRequestState::Merged
116 }
117
118 pub fn is_closed(&self) -> bool {
120 self.state == PullRequestState::Closed
121 }
122
123 pub fn close(&mut self) -> Result<()> {
125 if self.state == PullRequestState::Merged {
126 return Err(CollaborationError::InvalidStateTransition {
127 action: "close".to_string(),
128 current_state: self.state.to_string(),
129 });
130 }
131
132 self.state = PullRequestState::Closed;
133 self.updated_at = SystemTime::now()
134 .duration_since(UNIX_EPOCH)
135 .unwrap()
136 .as_secs();
137 Ok(())
138 }
139
140 pub fn reopen(&mut self) -> Result<()> {
142 if self.state != PullRequestState::Closed {
143 return Err(CollaborationError::InvalidStateTransition {
144 action: "reopen".to_string(),
145 current_state: self.state.to_string(),
146 });
147 }
148
149 self.state = PullRequestState::Open;
150 self.updated_at = SystemTime::now()
151 .duration_since(UNIX_EPOCH)
152 .unwrap()
153 .as_secs();
154 Ok(())
155 }
156
157 pub fn merge(&mut self, merged_by: impl Into<String>) -> Result<()> {
159 if self.state != PullRequestState::Open {
160 return Err(CollaborationError::InvalidStateTransition {
161 action: "merge".to_string(),
162 current_state: self.state.to_string(),
163 });
164 }
165
166 let now = SystemTime::now()
167 .duration_since(UNIX_EPOCH)
168 .unwrap()
169 .as_secs();
170
171 self.state = PullRequestState::Merged;
172 self.merged_at = Some(now);
173 self.merged_by = Some(merged_by.into());
174 self.updated_at = now;
175 Ok(())
176 }
177
178 pub fn update_title(&mut self, title: impl Into<String>) {
180 self.title = title.into();
181 self.updated_at = SystemTime::now()
182 .duration_since(UNIX_EPOCH)
183 .unwrap()
184 .as_secs();
185 }
186
187 pub fn update_description(&mut self, description: impl Into<String>) {
189 self.description = description.into();
190 self.updated_at = SystemTime::now()
191 .duration_since(UNIX_EPOCH)
192 .unwrap()
193 .as_secs();
194 }
195
196 pub fn add_label(&mut self, label: Label) {
198 if !self.labels.iter().any(|l| l.name == label.name) {
199 self.labels.push(label);
200 self.updated_at = SystemTime::now()
201 .duration_since(UNIX_EPOCH)
202 .unwrap()
203 .as_secs();
204 }
205 }
206
207 pub fn remove_label(&mut self, name: &str) {
209 let before = self.labels.len();
210 self.labels.retain(|l| l.name != name);
211 if self.labels.len() != before {
212 self.updated_at = SystemTime::now()
213 .duration_since(UNIX_EPOCH)
214 .unwrap()
215 .as_secs();
216 }
217 }
218
219 pub fn update_source_commit(&mut self, commit: ObjectId) {
221 self.source_commit = commit;
222 self.updated_at = SystemTime::now()
223 .duration_since(UNIX_EPOCH)
224 .unwrap()
225 .as_secs();
226 }
227}
228
229#[cfg(test)]
230mod tests {
231 use super::*;
232
233 fn create_test_pr() -> PullRequest {
234 PullRequest::new(
235 1,
236 "alice/repo",
237 1,
238 "Add feature X",
239 "This PR adds feature X",
240 "alice_pubkey",
241 "feature-x",
242 "main",
243 ObjectId::from_bytes([1u8; 20]),
244 ObjectId::from_bytes([2u8; 20]),
245 )
246 }
247
248 #[test]
249 fn test_pr_creation() {
250 let pr = create_test_pr();
251 assert_eq!(pr.number, 1);
252 assert_eq!(pr.title, "Add feature X");
253 assert!(pr.is_open());
254 assert!(!pr.is_merged());
255 assert!(!pr.is_closed());
256 }
257
258 #[test]
259 fn test_pr_close_and_reopen() {
260 let mut pr = create_test_pr();
261
262 pr.close().unwrap();
263 assert!(pr.is_closed());
264 assert!(!pr.is_open());
265
266 pr.reopen().unwrap();
267 assert!(pr.is_open());
268 assert!(!pr.is_closed());
269 }
270
271 #[test]
272 fn test_pr_merge() {
273 let mut pr = create_test_pr();
274
275 pr.merge("bob_pubkey").unwrap();
276 assert!(pr.is_merged());
277 assert!(!pr.is_open());
278 assert!(pr.merged_at.is_some());
279 assert_eq!(pr.merged_by, Some("bob_pubkey".to_string()));
280 }
281
282 #[test]
283 fn test_cannot_merge_closed_pr() {
284 let mut pr = create_test_pr();
285 pr.close().unwrap();
286
287 let result = pr.merge("bob");
288 assert!(result.is_err());
289 }
290
291 #[test]
292 fn test_cannot_close_merged_pr() {
293 let mut pr = create_test_pr();
294 pr.merge("bob").unwrap();
295
296 let result = pr.close();
297 assert!(result.is_err());
298 }
299
300 #[test]
301 fn test_labels() {
302 let mut pr = create_test_pr();
303
304 pr.add_label(Label::bug());
305 pr.add_label(Label::enhancement());
306 assert_eq!(pr.labels.len(), 2);
307
308 pr.add_label(Label::bug());
310 assert_eq!(pr.labels.len(), 2);
311
312 pr.remove_label("bug");
313 assert_eq!(pr.labels.len(), 1);
314 assert_eq!(pr.labels[0].name, "enhancement");
315 }
316}