Skip to main content

parley/domain/
review.rs

1use serde::{Deserialize, Serialize};
2
3#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
4#[serde(rename_all = "snake_case")]
5pub enum ReviewState {
6    Open,
7    UnderReview,
8    Done,
9}
10
11#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
12#[serde(rename_all = "snake_case")]
13pub enum Author {
14    User,
15    Ai,
16}
17
18#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
19#[serde(rename_all = "snake_case")]
20pub enum CommentStatus {
21    Open,
22    Pending,
23    Addressed,
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
27#[serde(rename_all = "snake_case")]
28pub enum DiffSide {
29    Left,
30    Right,
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
34pub struct CommentReply {
35    pub id: u64,
36    pub author: Author,
37    pub body: String,
38    pub created_at_ms: u64,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
42pub struct LineAnchorSnapshot {
43    pub target_code: String,
44    #[serde(default)]
45    pub before_context: Vec<String>,
46    #[serde(default)]
47    pub after_context: Vec<String>,
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
51pub struct LineComment {
52    pub id: u64,
53    pub file_path: String,
54    pub old_line: Option<u32>,
55    pub new_line: Option<u32>,
56    pub side: DiffSide,
57    #[serde(default)]
58    pub line_anchor: Option<LineAnchorSnapshot>,
59    #[serde(default)]
60    pub detached: bool,
61    pub body: String,
62    pub author: Author,
63    pub status: CommentStatus,
64    pub replies: Vec<CommentReply>,
65    pub created_at_ms: u64,
66    pub updated_at_ms: u64,
67    pub addressed_at_ms: Option<u64>,
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
71pub struct ReviewSession {
72    pub name: String,
73    pub state: ReviewState,
74    pub created_at_ms: u64,
75    pub updated_at_ms: u64,
76    pub done_at_ms: Option<u64>,
77    pub comments: Vec<LineComment>,
78    pub next_comment_id: u64,
79    pub next_reply_id: u64,
80}
81
82#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
83pub struct NewLineComment {
84    pub file_path: String,
85    pub old_line: Option<u32>,
86    pub new_line: Option<u32>,
87    pub side: DiffSide,
88    pub line_anchor: Option<LineAnchorSnapshot>,
89    pub body: String,
90    pub author: Author,
91}
92
93#[derive(Debug, Clone, PartialEq, Eq)]
94pub struct ReanchorLineComment {
95    pub file_path: String,
96    pub old_line: Option<u32>,
97    pub new_line: Option<u32>,
98    pub side: DiffSide,
99    pub line_anchor: Option<LineAnchorSnapshot>,
100}
101
102impl ReviewSession {
103    pub fn new(name: String, now_ms: u64) -> Self {
104        Self {
105            name,
106            state: ReviewState::Open,
107            created_at_ms: now_ms,
108            updated_at_ms: now_ms,
109            done_at_ms: None,
110            comments: Vec::new(),
111            next_comment_id: 1,
112            next_reply_id: 1,
113        }
114    }
115
116    pub fn set_state(&mut self, next: ReviewState, now_ms: u64) -> Result<(), String> {
117        self.set_state_with_options(next, now_ms, false)
118    }
119
120    pub fn set_state_force(&mut self, next: ReviewState, now_ms: u64) -> Result<(), String> {
121        self.set_state_with_options(next, now_ms, true)
122    }
123
124    fn set_state_with_options(
125        &mut self,
126        next: ReviewState,
127        now_ms: u64,
128        force_done: bool,
129    ) -> Result<(), String> {
130        if matches!(next, ReviewState::Done) {
131            let unresolved_threads = self
132                .comments
133                .iter()
134                .filter(|comment| !matches!(comment.status, CommentStatus::Addressed))
135                .count();
136            if unresolved_threads > 0 && !force_done {
137                return Err(format!(
138                    "cannot set review to done: {unresolved_threads} unresolved thread(s)"
139                ));
140            }
141        }
142
143        if matches!(next, ReviewState::Done) {
144            self.done_at_ms = Some(now_ms);
145        } else if matches!(self.state, ReviewState::Done) {
146            self.done_at_ms = None;
147        }
148        self.state = next;
149        self.updated_at_ms = now_ms;
150        Ok(())
151    }
152
153    pub fn add_comment(&mut self, new_comment: NewLineComment, now_ms: u64) -> u64 {
154        let id = self.next_comment_id;
155        self.next_comment_id += 1;
156
157        let comment = LineComment {
158            id,
159            file_path: new_comment.file_path,
160            old_line: new_comment.old_line,
161            new_line: new_comment.new_line,
162            side: new_comment.side,
163            line_anchor: new_comment.line_anchor,
164            detached: false,
165            body: new_comment.body,
166            author: new_comment.author,
167            status: CommentStatus::Open,
168            replies: Vec::new(),
169            created_at_ms: now_ms,
170            updated_at_ms: now_ms,
171            addressed_at_ms: None,
172        };
173
174        self.comments.push(comment);
175        self.reconcile_review_state_from_threads();
176        self.updated_at_ms = now_ms;
177        id
178    }
179
180    pub fn add_reply(
181        &mut self,
182        comment_id: u64,
183        author: Author,
184        body: String,
185        now_ms: u64,
186    ) -> Result<u64, String> {
187        let id = self.next_reply_id;
188        self.next_reply_id += 1;
189
190        let comment = self
191            .comments
192            .iter_mut()
193            .find(|comment| comment.id == comment_id)
194            .ok_or_else(|| format!("comment_id {comment_id} not found"))?;
195
196        comment.replies.push(CommentReply {
197            id,
198            author: author.clone(),
199            body,
200            created_at_ms: now_ms,
201        });
202        comment.updated_at_ms = now_ms;
203        if author == comment.author {
204            comment.status = CommentStatus::Open;
205            comment.addressed_at_ms = None;
206        } else {
207            comment.status = CommentStatus::Pending;
208            comment.addressed_at_ms = None;
209        }
210        self.reconcile_review_state_from_threads();
211        self.updated_at_ms = now_ms;
212        Ok(id)
213    }
214
215    pub fn reanchor_comment(
216        &mut self,
217        comment_id: u64,
218        target: ReanchorLineComment,
219        now_ms: u64,
220    ) -> Result<(), String> {
221        let comment = self
222            .comments
223            .iter_mut()
224            .find(|comment| comment.id == comment_id)
225            .ok_or_else(|| format!("comment_id {comment_id} not found"))?;
226
227        comment.file_path = target.file_path;
228        comment.old_line = target.old_line;
229        comment.new_line = target.new_line;
230        comment.side = target.side;
231        comment.line_anchor = target.line_anchor;
232        comment.detached = false;
233        comment.updated_at_ms = now_ms;
234        self.updated_at_ms = now_ms;
235        Ok(())
236    }
237
238    pub fn set_comment_status(
239        &mut self,
240        comment_id: u64,
241        status: CommentStatus,
242        actor: Author,
243        now_ms: u64,
244    ) -> Result<(), String> {
245        self.set_comment_status_with_actor(comment_id, status, now_ms, Some(actor))
246    }
247
248    pub fn set_comment_status_force(
249        &mut self,
250        comment_id: u64,
251        status: CommentStatus,
252        now_ms: u64,
253    ) -> Result<(), String> {
254        self.set_comment_status_with_actor(comment_id, status, now_ms, None)
255    }
256
257    fn set_comment_status_with_actor(
258        &mut self,
259        comment_id: u64,
260        status: CommentStatus,
261        now_ms: u64,
262        actor: Option<Author>,
263    ) -> Result<(), String> {
264        let comment = self
265            .comments
266            .iter_mut()
267            .find(|comment| comment.id == comment_id)
268            .ok_or_else(|| format!("comment_id {comment_id} not found"))?;
269
270        if let Some(actor) = actor {
271            match status {
272                CommentStatus::Addressed => {
273                    if comment.author != actor {
274                        return Err(
275                            "only the original commenter can mark a comment addressed".to_string()
276                        );
277                    }
278                }
279                CommentStatus::Open | CommentStatus::Pending => {
280                    if comment.author != actor {
281                        return Err(
282                            "only the original commenter can change thread status".to_string()
283                        );
284                    }
285                }
286            }
287        }
288
289        comment.status = status.clone();
290        comment.updated_at_ms = now_ms;
291        comment.addressed_at_ms = if matches!(status, CommentStatus::Addressed) {
292            Some(now_ms)
293        } else {
294            None
295        };
296
297        self.reconcile_review_state_from_threads();
298        self.updated_at_ms = now_ms;
299        Ok(())
300    }
301
302    fn reconcile_review_state_from_threads(&mut self) {
303        let has_open = self
304            .comments
305            .iter()
306            .any(|comment| matches!(comment.status, CommentStatus::Open));
307        let has_pending = self
308            .comments
309            .iter()
310            .any(|comment| matches!(comment.status, CommentStatus::Pending));
311        let has_unresolved = has_open || has_pending;
312
313        if matches!(self.state, ReviewState::Done) && has_unresolved {
314            self.state = ReviewState::Open;
315            self.done_at_ms = None;
316            return;
317        }
318        if matches!(self.state, ReviewState::Done) {
319            return;
320        }
321
322        self.state = if has_open {
323            ReviewState::Open
324        } else {
325            ReviewState::UnderReview
326        };
327    }
328}
329
330#[cfg(test)]
331mod tests {
332    use super::{Author, CommentStatus, DiffSide, NewLineComment, ReviewSession, ReviewState};
333
334    #[test]
335    fn set_state_should_allow_reopen_after_done() {
336        let mut session = ReviewSession::new("r1".into(), 1);
337        session
338            .set_state(ReviewState::Done, 2)
339            .expect("state should move to done");
340        assert_eq!(session.done_at_ms, Some(2));
341
342        session
343            .set_state(ReviewState::UnderReview, 3)
344            .expect("state should reopen");
345        assert_eq!(session.state, ReviewState::UnderReview);
346        assert_eq!(session.done_at_ms, None);
347    }
348
349    #[test]
350    fn set_state_done_should_require_no_unresolved_threads() {
351        let mut session = ReviewSession::new("r1".into(), 1);
352        session.add_comment(
353            NewLineComment {
354                file_path: "src/lib.rs".into(),
355                old_line: None,
356                new_line: Some(1),
357                side: DiffSide::Right,
358                line_anchor: None,
359                body: "needs refactor".into(),
360                author: Author::User,
361            },
362            2,
363        );
364
365        let result = session.set_state(ReviewState::Done, 3);
366        assert!(result.is_err());
367    }
368
369    #[test]
370    fn set_state_force_done_should_allow_unresolved_threads() {
371        let mut session = ReviewSession::new("r1".into(), 1);
372        session.add_comment(
373            NewLineComment {
374                file_path: "src/lib.rs".into(),
375                old_line: None,
376                new_line: Some(1),
377                side: DiffSide::Right,
378                line_anchor: None,
379                body: "needs refactor".into(),
380                author: Author::User,
381            },
382            2,
383        );
384
385        session
386            .set_state_force(ReviewState::Done, 3)
387            .expect("force done should bypass unresolved checks");
388        assert_eq!(session.state, ReviewState::Done);
389    }
390
391    #[test]
392    fn add_comment_should_reopen_done_review() {
393        let mut session = ReviewSession::new("r1".into(), 1);
394        session
395            .set_state(ReviewState::Done, 2)
396            .expect("state should move to done");
397
398        session.add_comment(
399            NewLineComment {
400                file_path: "src/lib.rs".into(),
401                old_line: None,
402                new_line: Some(1),
403                side: DiffSide::Right,
404                line_anchor: None,
405                body: "new thread".into(),
406                author: Author::User,
407            },
408            3,
409        );
410
411        assert_eq!(session.state, ReviewState::Open);
412        assert_eq!(session.done_at_ms, None);
413    }
414
415    #[test]
416    fn add_reply_from_ai_should_set_pending_and_under_review() {
417        let mut session = ReviewSession::new("r1".into(), 1);
418        let comment_id = session.add_comment(
419            NewLineComment {
420                file_path: "src/lib.rs".into(),
421                old_line: None,
422                new_line: Some(1),
423                side: DiffSide::Right,
424                line_anchor: None,
425                body: "needs refactor".into(),
426                author: Author::User,
427            },
428            2,
429        );
430
431        session
432            .add_reply(comment_id, Author::Ai, "fixed".into(), 3)
433            .expect("ai reply should be added");
434
435        assert_eq!(session.comments[0].status, CommentStatus::Pending);
436        assert_eq!(session.state, ReviewState::UnderReview);
437    }
438
439    #[test]
440    fn add_reply_from_original_commenter_should_reopen_thread() {
441        let mut session = ReviewSession::new("r1".into(), 1);
442        let comment_id = session.add_comment(
443            NewLineComment {
444                file_path: "src/lib.rs".into(),
445                old_line: None,
446                new_line: Some(1),
447                side: DiffSide::Right,
448                line_anchor: None,
449                body: "needs refactor".into(),
450                author: Author::User,
451            },
452            2,
453        );
454        session
455            .add_reply(comment_id, Author::Ai, "proposal".into(), 3)
456            .expect("ai reply should be added");
457
458        session
459            .add_reply(comment_id, Author::User, "please revise".into(), 4)
460            .expect("user reply should be added");
461
462        assert_eq!(session.comments[0].status, CommentStatus::Open);
463        assert_eq!(session.state, ReviewState::Open);
464    }
465
466    #[test]
467    fn set_comment_status_should_require_original_commenter() {
468        let mut session = ReviewSession::new("r1".into(), 1);
469        let comment_id = session.add_comment(
470            NewLineComment {
471                file_path: "src/lib.rs".into(),
472                old_line: None,
473                new_line: Some(1),
474                side: DiffSide::Right,
475                line_anchor: None,
476                body: "needs refactor".into(),
477                author: Author::User,
478            },
479            2,
480        );
481
482        let result =
483            session.set_comment_status(comment_id, CommentStatus::Addressed, Author::Ai, 3);
484        assert!(result.is_err());
485    }
486
487    #[test]
488    fn set_comment_status_force_should_bypass_original_commenter_check() {
489        let mut session = ReviewSession::new("r1".into(), 1);
490        let comment_id = session.add_comment(
491            NewLineComment {
492                file_path: "src/lib.rs".into(),
493                old_line: None,
494                new_line: Some(1),
495                side: DiffSide::Right,
496                line_anchor: None,
497                body: "needs refactor".into(),
498                author: Author::User,
499            },
500            2,
501        );
502
503        session
504            .set_comment_status_force(comment_id, CommentStatus::Addressed, 3)
505            .expect("force close should bypass author ownership");
506        assert_eq!(session.comments[0].status, CommentStatus::Addressed);
507    }
508
509    #[test]
510    fn all_addressed_should_reconcile_to_under_review() {
511        let mut session = ReviewSession::new("r1".into(), 1);
512        let comment_id = session.add_comment(
513            NewLineComment {
514                file_path: "src/lib.rs".into(),
515                old_line: None,
516                new_line: Some(1),
517                side: DiffSide::Right,
518                line_anchor: None,
519                body: "needs refactor".into(),
520                author: Author::User,
521            },
522            2,
523        );
524        session
525            .set_comment_status(comment_id, CommentStatus::Addressed, Author::User, 3)
526            .expect("status should update");
527
528        assert_eq!(session.state, ReviewState::UnderReview);
529    }
530}