guts_collaboration/
pull_request.rs

1//! Pull Request types and state management.
2
3use guts_storage::ObjectId;
4use serde::{Deserialize, Serialize};
5use std::time::{SystemTime, UNIX_EPOCH};
6
7use crate::{CollaborationError, Label, Result};
8
9/// State of a pull request.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
11#[serde(rename_all = "lowercase")]
12pub enum PullRequestState {
13    /// Pull request is open and can be reviewed/merged.
14    Open,
15    /// Pull request was closed without merging.
16    Closed,
17    /// Pull request was merged into the target branch.
18    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/// A pull request for proposing changes to a repository.
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct PullRequest {
34    /// Unique identifier within the store.
35    pub id: u64,
36    /// Repository key (owner/repo).
37    pub repo_key: String,
38    /// Pull request number within the repository (#1, #2, etc.).
39    pub number: u32,
40    /// Title of the pull request.
41    pub title: String,
42    /// Description/body of the pull request (Markdown).
43    pub description: String,
44    /// Author's public key (hex encoded).
45    pub author: String,
46    /// Current state of the pull request.
47    pub state: PullRequestState,
48    /// Source branch name.
49    pub source_branch: String,
50    /// Target branch name (usually "main" or "master").
51    pub target_branch: String,
52    /// Head commit of the source branch.
53    pub source_commit: ObjectId,
54    /// Head commit of the target branch at PR creation.
55    pub target_commit: ObjectId,
56    /// Labels applied to this pull request.
57    pub labels: Vec<Label>,
58    /// Unix timestamp when the PR was created.
59    pub created_at: u64,
60    /// Unix timestamp when the PR was last updated.
61    pub updated_at: u64,
62    /// Unix timestamp when the PR was merged (if merged).
63    pub merged_at: Option<u64>,
64    /// Public key of the user who merged the PR (if merged).
65    pub merged_by: Option<String>,
66}
67
68impl PullRequest {
69    /// Creates a new pull request.
70    #[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    /// Returns true if the pull request is open.
109    pub fn is_open(&self) -> bool {
110        self.state == PullRequestState::Open
111    }
112
113    /// Returns true if the pull request is merged.
114    pub fn is_merged(&self) -> bool {
115        self.state == PullRequestState::Merged
116    }
117
118    /// Returns true if the pull request is closed (not merged).
119    pub fn is_closed(&self) -> bool {
120        self.state == PullRequestState::Closed
121    }
122
123    /// Closes the pull request without merging.
124    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    /// Reopens a closed pull request.
141    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    /// Merges the pull request.
158    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    /// Updates the title.
179    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    /// Updates the description.
188    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    /// Adds a label.
197    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    /// Removes a label by name.
208    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    /// Updates the source commit (when new commits are pushed).
220    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        // Adding same label twice doesn't duplicate
309        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}