Skip to main content

ta_changeset/
review_session.rs

1// review_session.rs — ReviewSession: persistent multi-interaction review state.
2//
3// A ReviewSession tracks the state of a human reviewer working through a DraftPackage
4// across multiple CLI invocations. It includes:
5// - Which artifacts have been reviewed
6// - Per-artifact comment threads
7// - Current review focus (which artifact the reviewer is examining)
8// - Session metadata (created, last updated, reviewer identity)
9//
10// This enables workflows like:
11//   ta draft review start <draft-id>
12//   ta draft review comment <artifact-uri> "needs error handling"
13//   ta draft review next
14//   ta draft review comment <artifact-uri> "looks good"
15//   ta draft review finish --approve "src/**" --reject "config.toml"
16
17use chrono::{DateTime, Utc};
18use serde::{Deserialize, Serialize};
19use std::collections::HashMap;
20use uuid::Uuid;
21
22use crate::draft_package::ArtifactDisposition;
23
24/// A persistent review session for a DraftPackage.
25///
26/// Tracks the reviewer's progress through a draft across multiple CLI invocations,
27/// including comments, dispositions, and current focus.
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct ReviewSession {
30    /// Unique session identifier.
31    pub session_id: Uuid,
32    /// The DraftPackage being reviewed.
33    pub draft_package_id: Uuid,
34    /// Reviewer identity.
35    pub reviewer: String,
36    /// Session creation time.
37    pub created_at: DateTime<Utc>,
38    /// Last activity time.
39    pub updated_at: DateTime<Utc>,
40    /// Current review state.
41    pub state: ReviewState,
42    /// Per-artifact review data (keyed by resource_uri).
43    pub artifact_reviews: HashMap<String, ArtifactReview>,
44    /// Session-level notes (not tied to specific artifacts).
45    #[serde(default, skip_serializing_if = "Vec::is_empty")]
46    pub session_notes: Vec<SessionNote>,
47    /// Current focus: which artifact URI the reviewer is examining.
48    /// Used by "ta draft review next" to resume from where they left off.
49    #[serde(skip_serializing_if = "Option::is_none")]
50    pub current_focus: Option<String>,
51}
52
53impl ReviewSession {
54    /// Create a new review session for a draft package.
55    pub fn new(draft_package_id: Uuid, reviewer: String) -> Self {
56        Self {
57            session_id: Uuid::new_v4(),
58            draft_package_id,
59            reviewer,
60            created_at: Utc::now(),
61            updated_at: Utc::now(),
62            state: ReviewState::Active,
63            artifact_reviews: HashMap::new(),
64            session_notes: Vec::new(),
65            current_focus: None,
66        }
67    }
68
69    /// Mark the session as updated (call after any mutation).
70    pub fn touch(&mut self) {
71        self.updated_at = Utc::now();
72    }
73
74    /// Add a comment to an artifact.
75    pub fn add_comment(
76        &mut self,
77        artifact_uri: &str,
78        commenter: &str,
79        text: &str,
80    ) -> &CommentThread {
81        self.touch();
82        let review = self
83            .artifact_reviews
84            .entry(artifact_uri.to_string())
85            .or_insert_with(|| ArtifactReview {
86                resource_uri: artifact_uri.to_string(),
87                disposition: ArtifactDisposition::Pending,
88                comments: CommentThread::new(),
89                reviewed_at: None,
90            });
91        review.comments.add(commenter, text);
92        &review.comments
93    }
94
95    /// Set the disposition for an artifact.
96    pub fn set_disposition(&mut self, artifact_uri: &str, disposition: ArtifactDisposition) {
97        self.touch();
98        let review = self
99            .artifact_reviews
100            .entry(artifact_uri.to_string())
101            .or_insert_with(|| ArtifactReview {
102                resource_uri: artifact_uri.to_string(),
103                disposition: ArtifactDisposition::Pending,
104                comments: CommentThread::new(),
105                reviewed_at: None,
106            });
107        review.disposition = disposition;
108        review.reviewed_at = Some(Utc::now());
109    }
110
111    /// Add a session-level note (not tied to a specific artifact).
112    pub fn add_session_note(&mut self, text: &str) {
113        self.touch();
114        self.session_notes.push(SessionNote {
115            text: text.to_string(),
116            created_at: Utc::now(),
117        });
118    }
119
120    /// Get the current disposition for an artifact (None if not yet reviewed).
121    pub fn get_disposition(&self, artifact_uri: &str) -> Option<ArtifactDisposition> {
122        self.artifact_reviews
123            .get(artifact_uri)
124            .map(|r| r.disposition.clone())
125    }
126
127    /// Get all artifacts with a specific disposition.
128    pub fn artifacts_with_disposition(
129        &self,
130        disposition: &ArtifactDisposition,
131    ) -> Vec<&ArtifactReview> {
132        self.artifact_reviews
133            .values()
134            .filter(|r| &r.disposition == disposition)
135            .collect()
136    }
137
138    /// Count artifacts by disposition.
139    pub fn disposition_counts(&self) -> DispositionCounts {
140        let mut counts = DispositionCounts::default();
141        for review in self.artifact_reviews.values() {
142            match review.disposition {
143                ArtifactDisposition::Pending => counts.pending += 1,
144                ArtifactDisposition::Approved => counts.approved += 1,
145                ArtifactDisposition::Rejected => counts.rejected += 1,
146                ArtifactDisposition::Discuss => counts.discuss += 1,
147            }
148        }
149        counts
150    }
151
152    /// Finish the review session and return final disposition summary.
153    pub fn finish(&mut self) -> DispositionCounts {
154        self.touch();
155        self.state = ReviewState::Completed;
156        self.disposition_counts()
157    }
158
159    /// Check if the session has any unresolved discuss items.
160    pub fn has_unresolved_discuss(&self) -> bool {
161        self.artifact_reviews
162            .values()
163            .any(|r| r.disposition == ArtifactDisposition::Discuss)
164    }
165}
166
167/// Review state lifecycle.
168#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
169#[serde(rename_all = "snake_case")]
170pub enum ReviewState {
171    /// Session is active (reviewer is working through artifacts).
172    Active,
173    /// Session is paused (can be resumed later).
174    Paused,
175    /// Session is completed (review finished, dispositions finalized).
176    Completed,
177    /// Session was abandoned (reviewer gave up or session expired).
178    Abandoned,
179}
180
181/// Review data for a single artifact.
182#[derive(Debug, Clone, Serialize, Deserialize)]
183pub struct ArtifactReview {
184    /// The artifact's resource URI.
185    pub resource_uri: String,
186    /// Current disposition (defaults to Pending).
187    pub disposition: ArtifactDisposition,
188    /// Comment thread for this artifact.
189    pub comments: CommentThread,
190    /// When this artifact was last reviewed (disposition set).
191    #[serde(skip_serializing_if = "Option::is_none")]
192    pub reviewed_at: Option<DateTime<Utc>>,
193}
194
195/// A thread of comments on an artifact.
196///
197/// Supports multi-party discussion: reviewer, agent, other reviewers, etc.
198#[derive(Debug, Clone, Serialize, Deserialize)]
199pub struct CommentThread {
200    pub comments: Vec<Comment>,
201}
202
203impl CommentThread {
204    pub fn new() -> Self {
205        Self {
206            comments: Vec::new(),
207        }
208    }
209
210    /// Add a comment to the thread.
211    pub fn add(&mut self, commenter: &str, text: &str) {
212        self.comments.push(Comment {
213            commenter: commenter.to_string(),
214            text: text.to_string(),
215            created_at: Utc::now(),
216            reasoning: None,
217        });
218    }
219
220    /// Add a comment with structured reasoning to the thread (v0.3.3).
221    pub fn add_with_reasoning(&mut self, commenter: &str, text: &str, reasoning: ReviewReasoning) {
222        self.comments.push(Comment {
223            commenter: commenter.to_string(),
224            text: text.to_string(),
225            created_at: Utc::now(),
226            reasoning: Some(reasoning),
227        });
228    }
229
230    /// Check if the thread is empty.
231    pub fn is_empty(&self) -> bool {
232        self.comments.is_empty()
233    }
234
235    /// Get the number of comments in the thread.
236    pub fn len(&self) -> usize {
237        self.comments.len()
238    }
239}
240
241impl Default for CommentThread {
242    fn default() -> Self {
243        Self::new()
244    }
245}
246
247/// A single comment in a thread.
248#[derive(Debug, Clone, Serialize, Deserialize)]
249pub struct Comment {
250    /// Who wrote the comment (human reviewer, agent, etc.).
251    pub commenter: String,
252    /// Comment text (markdown supported).
253    pub text: String,
254    /// When the comment was created.
255    pub created_at: DateTime<Utc>,
256    /// Structured reasoning for this review decision (v0.3.3).
257    /// Reviewer can explain *why* they approved/rejected, not just leave text.
258    #[serde(default, skip_serializing_if = "Option::is_none")]
259    pub reasoning: Option<ReviewReasoning>,
260}
261
262/// Structured reasoning attached to a review comment (v0.3.3).
263///
264/// Enables compliance reporting: reviewers document *why* they approved or rejected,
265/// what alternatives they considered, and what principles guided the decision.
266#[derive(Debug, Clone, Serialize, Deserialize)]
267pub struct ReviewReasoning {
268    /// The reviewer's rationale for their decision.
269    pub rationale: String,
270    /// Alternatives the reviewer considered.
271    #[serde(default, skip_serializing_if = "Vec::is_empty")]
272    pub alternatives_considered: Vec<String>,
273    /// Principles or policies that informed the decision.
274    #[serde(default, skip_serializing_if = "Vec::is_empty")]
275    pub applied_principles: Vec<String>,
276}
277
278/// Session-level note (not tied to a specific artifact).
279#[derive(Debug, Clone, Serialize, Deserialize)]
280pub struct SessionNote {
281    pub text: String,
282    pub created_at: DateTime<Utc>,
283}
284
285/// Summary counts of artifact dispositions.
286#[derive(Debug, Clone, Default)]
287pub struct DispositionCounts {
288    pub pending: usize,
289    pub approved: usize,
290    pub rejected: usize,
291    pub discuss: usize,
292}
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297
298    #[test]
299    fn new_session_has_active_state() {
300        let session = ReviewSession::new(Uuid::new_v4(), "reviewer-1".to_string());
301        assert_eq!(session.state, ReviewState::Active);
302        assert!(session.artifact_reviews.is_empty());
303        assert!(session.session_notes.is_empty());
304        assert!(session.current_focus.is_none());
305    }
306
307    #[test]
308    fn add_comment_creates_artifact_review() {
309        let mut session = ReviewSession::new(Uuid::new_v4(), "reviewer-1".to_string());
310        let uri = "fs://workspace/src/main.rs";
311
312        session.add_comment(uri, "reviewer-1", "Needs error handling");
313
314        assert_eq!(session.artifact_reviews.len(), 1);
315        let review = session.artifact_reviews.get(uri).unwrap();
316        assert_eq!(review.comments.len(), 1);
317        assert_eq!(review.comments.comments[0].text, "Needs error handling");
318        assert_eq!(review.disposition, ArtifactDisposition::Pending);
319    }
320
321    #[test]
322    fn set_disposition_updates_review() {
323        let mut session = ReviewSession::new(Uuid::new_v4(), "reviewer-1".to_string());
324        let uri = "fs://workspace/src/main.rs";
325
326        session.set_disposition(uri, ArtifactDisposition::Approved);
327
328        let review = session.artifact_reviews.get(uri).unwrap();
329        assert_eq!(review.disposition, ArtifactDisposition::Approved);
330        assert!(review.reviewed_at.is_some());
331    }
332
333    #[test]
334    fn disposition_counts_are_accurate() {
335        let mut session = ReviewSession::new(Uuid::new_v4(), "reviewer-1".to_string());
336
337        session.set_disposition("fs://workspace/a.rs", ArtifactDisposition::Approved);
338        session.set_disposition("fs://workspace/b.rs", ArtifactDisposition::Approved);
339        session.set_disposition("fs://workspace/c.rs", ArtifactDisposition::Rejected);
340        session.set_disposition("fs://workspace/d.rs", ArtifactDisposition::Discuss);
341
342        let counts = session.disposition_counts();
343        assert_eq!(counts.approved, 2);
344        assert_eq!(counts.rejected, 1);
345        assert_eq!(counts.discuss, 1);
346        assert_eq!(counts.pending, 0);
347    }
348
349    #[test]
350    fn has_unresolved_discuss_returns_true_when_discuss_items_exist() {
351        let mut session = ReviewSession::new(Uuid::new_v4(), "reviewer-1".to_string());
352
353        session.set_disposition("fs://workspace/a.rs", ArtifactDisposition::Approved);
354        session.set_disposition("fs://workspace/b.rs", ArtifactDisposition::Discuss);
355
356        assert!(session.has_unresolved_discuss());
357    }
358
359    #[test]
360    fn has_unresolved_discuss_returns_false_when_no_discuss_items() {
361        let mut session = ReviewSession::new(Uuid::new_v4(), "reviewer-1".to_string());
362
363        session.set_disposition("fs://workspace/a.rs", ArtifactDisposition::Approved);
364        session.set_disposition("fs://workspace/b.rs", ArtifactDisposition::Rejected);
365
366        assert!(!session.has_unresolved_discuss());
367    }
368
369    #[test]
370    fn finish_sets_state_to_completed() {
371        let mut session = ReviewSession::new(Uuid::new_v4(), "reviewer-1".to_string());
372        session.set_disposition("fs://workspace/a.rs", ArtifactDisposition::Approved);
373
374        let counts = session.finish();
375
376        assert_eq!(session.state, ReviewState::Completed);
377        assert_eq!(counts.approved, 1);
378    }
379
380    #[test]
381    fn session_serialization_round_trip() {
382        let mut session = ReviewSession::new(Uuid::new_v4(), "reviewer-1".to_string());
383        session.add_comment("fs://workspace/main.rs", "reviewer-1", "Looks good");
384        session.set_disposition("fs://workspace/main.rs", ArtifactDisposition::Approved);
385        session.add_session_note("Overall: well structured");
386
387        let json = serde_json::to_string(&session).unwrap();
388        let restored: ReviewSession = serde_json::from_str(&json).unwrap();
389
390        assert_eq!(restored.session_id, session.session_id);
391        assert_eq!(restored.reviewer, session.reviewer);
392        assert_eq!(restored.artifact_reviews.len(), 1);
393        assert_eq!(restored.session_notes.len(), 1);
394    }
395
396    #[test]
397    fn comment_thread_tracks_multiple_comments() {
398        let mut thread = CommentThread::new();
399        thread.add("reviewer-1", "First comment");
400        thread.add("agent-1", "Response from agent");
401        thread.add("reviewer-1", "Follow-up");
402
403        assert_eq!(thread.len(), 3);
404        assert_eq!(thread.comments[0].commenter, "reviewer-1");
405        assert_eq!(thread.comments[1].commenter, "agent-1");
406        assert_eq!(thread.comments[2].commenter, "reviewer-1");
407    }
408
409    #[test]
410    fn artifacts_with_disposition_filters_correctly() {
411        let mut session = ReviewSession::new(Uuid::new_v4(), "reviewer-1".to_string());
412        session.set_disposition("fs://workspace/a.rs", ArtifactDisposition::Approved);
413        session.set_disposition("fs://workspace/b.rs", ArtifactDisposition::Approved);
414        session.set_disposition("fs://workspace/c.rs", ArtifactDisposition::Rejected);
415
416        let approved = session.artifacts_with_disposition(&ArtifactDisposition::Approved);
417        assert_eq!(approved.len(), 2);
418
419        let rejected = session.artifacts_with_disposition(&ArtifactDisposition::Rejected);
420        assert_eq!(rejected.len(), 1);
421    }
422
423    // ── v0.3.3 Review Reasoning tests ──
424
425    #[test]
426    fn comment_with_reasoning_round_trip() {
427        let reasoning = ReviewReasoning {
428            rationale: "Change is well-tested and follows conventions".to_string(),
429            alternatives_considered: vec!["Request rework with different approach".to_string()],
430            applied_principles: vec!["code-review-checklist".to_string()],
431        };
432
433        let mut thread = CommentThread::new();
434        thread.add_with_reasoning("reviewer-1", "Approved with minor note", reasoning);
435
436        assert_eq!(thread.len(), 1);
437        assert!(thread.comments[0].reasoning.is_some());
438
439        let r = thread.comments[0].reasoning.as_ref().unwrap();
440        assert!(r.rationale.contains("well-tested"));
441        assert_eq!(r.alternatives_considered.len(), 1);
442        assert_eq!(r.applied_principles.len(), 1);
443    }
444
445    #[test]
446    fn comment_without_reasoning_backward_compatible() {
447        // Old JSON without reasoning field should deserialize fine.
448        let json = r#"{
449            "commenter": "reviewer-1",
450            "text": "Looks good",
451            "created_at": "2026-02-25T12:00:00Z"
452        }"#;
453        let comment: Comment = serde_json::from_str(json).unwrap();
454        assert!(comment.reasoning.is_none());
455    }
456
457    #[test]
458    fn review_reasoning_serialization() {
459        let reasoning = ReviewReasoning {
460            rationale: "Security fix verified".to_string(),
461            alternatives_considered: vec![],
462            applied_principles: vec!["security-first".to_string()],
463        };
464
465        let json = serde_json::to_string(&reasoning).unwrap();
466        let restored: ReviewReasoning = serde_json::from_str(&json).unwrap();
467
468        assert_eq!(restored.rationale, "Security fix verified");
469        // Empty alternatives_considered should be skipped in serialization.
470        assert!(!json.contains("alternatives_considered"));
471    }
472}