crates/forge/ents-forge/src/review/entity.rs
entity.rshistorycomment on this file
| 1 | //! The Review entity: a verdict plus a context — the id of the most |
| 2 | //! recently reviewed commit, a verdict, and a body. |
| 3 | //! |
| 4 | //! Spec coverage: `model.review`. |
| 5 | |
| 6 | use ents_attrs as ents; |
| 7 | use facet::Facet; |
| 8 | use gix_hash::ObjectId; |
| 9 | |
| 10 | /// A review's lifecycle state (`model.review`): whether the reviewer's |
| 11 | /// verdict still stands (`Active`) or the reviewer has retracted it |
| 12 | /// (`Withdrawn`). Withdrawal is append-only — [`super::command::withdraw`] |
| 13 | /// writes a new [`Review`] entity carrying this variant onto the *same* |
| 14 | /// ref chain rather than deleting anything, so a withdrawn verdict remains |
| 15 | /// in `refs/meta/reviews/<target>/<member>`'s history: the chain is the |
| 16 | /// audit trail. Web aggregate views (the `/reviews` list, a commit's own |
| 17 | /// reviews section) filter `Withdrawn` rows out of what they render, but |
| 18 | /// nothing here or in `super::command` ever removes the ref, the object, |
| 19 | /// or an earlier commit naming `Active`. |
| 20 | /// |
| 21 | /// `Active` is this type's [`Default`] and the [`Review`] field carrying it |
| 22 | /// is `#[facet(default)]` for exactly one reason: every review tree written |
| 23 | /// before this variant existed has no `state` entry at all, and must still |
| 24 | /// read back as a plain, unretracted review rather than fail to decode |
| 25 | /// (backward compatibility with every review recorded before this change). |
| 26 | /// |
| 27 | /// Parses from and renders as its kebab-case convention names (`active`, |
| 28 | /// `withdrawn`), the same convention [`Verdict`] follows. |
| 29 | /// |
| 30 | /// # Examples |
| 31 | /// |
| 32 | /// ``` |
| 33 | /// use ents_forge::review::ReviewState; |
| 34 | /// |
| 35 | /// let state: ReviewState = "withdrawn".parse().expect("known state"); |
| 36 | /// assert_eq!(state, ReviewState::Withdrawn); |
| 37 | /// assert_eq!(state.to_string(), "withdrawn"); |
| 38 | /// assert_eq!(ReviewState::default(), ReviewState::Active); |
| 39 | /// ``` |
| 40 | // @relation(model.review, meta-ref.typed-tree, scope=type) |
| 41 | #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Facet)] |
| 42 | #[repr(u8)] |
| 43 | pub enum ReviewState { |
| 44 | /// The reviewer's verdict still stands. |
| 45 | #[default] |
| 46 | Active, |
| 47 | /// The reviewer has retracted this review; the verdict and body stay |
| 48 | /// in history, unread by aggregate views. |
| 49 | Withdrawn, |
| 50 | } |
| 51 | |
| 52 | impl std::str::FromStr for ReviewState { |
| 53 | type Err = crate::Error; |
| 54 | |
| 55 | fn from_str(text: &str) -> Result<Self, Self::Err> { |
| 56 | match text { |
| 57 | "active" => Ok(Self::Active), |
| 58 | "withdrawn" => Ok(Self::Withdrawn), |
| 59 | other => Err(crate::Error::InvalidArgument(format!( |
| 60 | "unknown review state {other:?}: expected active or withdrawn" |
| 61 | ))), |
| 62 | } |
| 63 | } |
| 64 | } |
| 65 | |
| 66 | impl std::fmt::Display for ReviewState { |
| 67 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
| 68 | f.write_str(match self { |
| 69 | Self::Active => "active", |
| 70 | Self::Withdrawn => "withdrawn", |
| 71 | }) |
| 72 | } |
| 73 | } |
| 74 | |
| 75 | /// A review's verdict (`model.review`): a hard enum, unlike issue and |
| 76 | /// comment states — a verdict gates decisions, so its vocabulary is |
| 77 | /// platform, not schema. |
| 78 | /// |
| 79 | /// Parses from and renders as its kebab-case convention names |
| 80 | /// (`approve`, `request-changes`, `comment`), the same strings every |
| 81 | /// surface shows. |
| 82 | /// |
| 83 | /// # Examples |
| 84 | /// |
| 85 | /// ``` |
| 86 | /// use ents_forge::review::Verdict; |
| 87 | /// |
| 88 | /// let verdict: Verdict = "request-changes".parse().expect("known verdict"); |
| 89 | /// assert_eq!(verdict, Verdict::RequestChanges); |
| 90 | /// assert_eq!(verdict.to_string(), "request-changes"); |
| 91 | /// assert!("needs-design-doc".parse::<Verdict>().is_err()); |
| 92 | /// ``` |
| 93 | // @relation(model.review, scope=type) |
| 94 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Facet)] |
| 95 | #[repr(u8)] |
| 96 | pub enum Verdict { |
| 97 | /// The reviewed content is accepted. |
| 98 | Approve, |
| 99 | /// The reviewed content needs changes before acceptance. |
| 100 | RequestChanges, |
| 101 | /// Judgment withheld: the review exists for its body and thread. |
| 102 | Comment, |
| 103 | } |
| 104 | |
| 105 | impl std::str::FromStr for Verdict { |
| 106 | type Err = crate::Error; |
| 107 | |
| 108 | fn from_str(text: &str) -> Result<Self, Self::Err> { |
| 109 | match text { |
| 110 | "approve" => Ok(Self::Approve), |
| 111 | "request-changes" => Ok(Self::RequestChanges), |
| 112 | "comment" => Ok(Self::Comment), |
| 113 | other => Err(crate::Error::InvalidArgument(format!( |
| 114 | "unknown verdict {other:?}: expected approve, request-changes, or comment" |
| 115 | ))), |
| 116 | } |
| 117 | } |
| 118 | } |
| 119 | |
| 120 | impl std::fmt::Display for Verdict { |
| 121 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
| 122 | f.write_str(match self { |
| 123 | Self::Approve => "approve", |
| 124 | Self::RequestChanges => "request-changes", |
| 125 | Self::Comment => "comment", |
| 126 | }) |
| 127 | } |
| 128 | } |
| 129 | |
| 130 | /// A verdict on a commit, plus a body (`model.review`). |
| 131 | /// |
| 132 | /// Every review occupies exactly two refs: this entity's own tree at |
| 133 | /// `refs/meta/reviews/<target>/<member>`, and a retention pin at |
| 134 | /// `refs/meta/pins/reviews/<target>/<member>` anchoring the reviewed content |
| 135 | /// itself (`model.review-pin`) — [`super::new`] writes both. `target` is |
| 136 | /// the oid of the most recently reviewed commit, stored as a plain data |
| 137 | /// field the same way [`ents_model::ResultRecord`]'s own `target` field |
| 138 | /// stores the commit it judged: a `[u8; 20]` field plus a [`Review::target`] |
| 139 | /// accessor, so reading it back never requires the pin ref — the pin |
| 140 | /// anchors reachability, the entity describes what was reviewed. At |
| 141 | /// genesis this field equals the refname's `<target>` segment and binds |
| 142 | /// it (`meta-ref.identity-binding`); re-reviewing a descendant advances |
| 143 | /// this field while the refname stays keyed by genesis |
| 144 | /// (`model.review-pin`). The verdict is a hard [`Verdict`] enum — unlike |
| 145 | /// the open state vocabularies on [`crate::Issue`] and |
| 146 | /// [`crate::comment::Comment`], a verdict gates decisions, so its |
| 147 | /// vocabulary is platform, not schema. Reviewer and |
| 148 | /// timestamp come from the mutation commit chain rather than a stored |
| 149 | /// field (`meta-ref.identity-binding`), so `Review` carries no author or |
| 150 | /// timestamp field — the same omission [`crate::comment::Comment`] makes. |
| 151 | /// A review's discussion is [`crate::comment::Comment`] entities naming |
| 152 | /// the review as their context (`model.comment-context`); `Review` itself |
| 153 | /// stores no list of its comments. |
| 154 | /// |
| 155 | /// # Examples |
| 156 | /// |
| 157 | /// ``` |
| 158 | /// use ents_forge::review::Review; |
| 159 | /// |
| 160 | /// let target = gix_hash::ObjectId::from_hex(b"0123456789abcdef0123456789abcdef01234567") |
| 161 | /// .expect("valid hex"); |
| 162 | /// let review = Review::new(target, ents_forge::review::Verdict::Approve, "looks good"); |
| 163 | /// let (root, store) = facet_git_tree::serialize(&review).expect("serialize"); |
| 164 | /// let back: Review = facet_git_tree::deserialize(&root, &store).expect("deserialize"); |
| 165 | /// assert_eq!(back, review); |
| 166 | /// assert_eq!(back.target(), target); |
| 167 | /// ``` |
| 168 | // @relation(model.review, meta-ref.identity-binding, meta-ref.typed-tree, model.extensibility, scope=file) |
| 169 | #[derive(Debug, Clone, PartialEq, Eq, Facet)] |
| 170 | pub struct Review { |
| 171 | #[facet(ents::head, ents::id)] |
| 172 | target: [u8; 20], |
| 173 | /// The review's verdict (`model.review`): a fixed [`Verdict`], not a |
| 174 | /// string. |
| 175 | #[facet(ents::head)] |
| 176 | pub verdict: Verdict, |
| 177 | /// The review's body text. |
| 178 | #[facet(ents::body)] |
| 179 | pub body: String, |
| 180 | /// Whether this review still stands or has been withdrawn |
| 181 | /// (`model.review`). `#[facet(default)]` so a tree written before this |
| 182 | /// field existed — no `state` entry at all — deserializes to |
| 183 | /// [`ReviewState::Active`] rather than failing to decode; every |
| 184 | /// existing `refs/meta/reviews/*` history predates this field and must |
| 185 | /// keep reading. |
| 186 | #[facet(default, ents::head)] |
| 187 | pub state: ReviewState, |
| 188 | } |
| 189 | |
| 190 | impl Review { |
| 191 | /// Build a review of `target` carrying `verdict` and `body` |
| 192 | /// (`model.review`), initially [`ReviewState::Active`] — every review |
| 193 | /// starts active; only [`super::command::withdraw`] ever writes |
| 194 | /// [`ReviewState::Withdrawn`]. |
| 195 | #[must_use] |
| 196 | pub fn new(target: ObjectId, verdict: Verdict, body: impl Into<String>) -> Self { |
| 197 | let mut bytes = [0u8; 20]; |
| 198 | bytes.copy_from_slice(target.as_slice()); |
| 199 | Self { |
| 200 | target: bytes, |
| 201 | verdict, |
| 202 | body: body.into(), |
| 203 | state: ReviewState::Active, |
| 204 | } |
| 205 | } |
| 206 | |
| 207 | /// The id of the most recently reviewed commit (`model.review`): |
| 208 | /// reading this never requires the pin ref |
| 209 | /// (`refs/meta/pins/reviews/<target>/<member>`) — the pin anchors |
| 210 | /// reachability, the entity describes what was reviewed. |
| 211 | #[must_use] |
| 212 | pub fn target(&self) -> ObjectId { |
| 213 | ObjectId::from_bytes_or_panic(&self.target) |
| 214 | } |
| 215 | |
| 216 | /// A copy of this review with its state advanced to |
| 217 | /// [`ReviewState::Withdrawn`], preserving `target`, `verdict`, and |
| 218 | /// `body` exactly (`model.review`): [`super::command::withdraw`] writes |
| 219 | /// this new entity onto the same ref chain the original review |
| 220 | /// occupies — append-only, so the prior [`Active`](ReviewState::Active) |
| 221 | /// commit stays reachable in history — rather than mutating anything |
| 222 | /// in place. |
| 223 | #[must_use] |
| 224 | pub fn withdrawn(&self) -> Self { |
| 225 | Self { |
| 226 | state: ReviewState::Withdrawn, |
| 227 | ..self.clone() |
| 228 | } |
| 229 | } |
| 230 | } |
| 231 | |
| 232 | #[cfg(test)] |
| 233 | mod tests { |
| 234 | #![allow(clippy::expect_used, reason = "unit test")] |
| 235 | |
| 236 | use facet_git_tree::{deserialize, serialize}; |
| 237 | use rstest::rstest; |
| 238 | |
| 239 | use super::*; |
| 240 | |
| 241 | #[rstest] |
| 242 | #[case::approve(Verdict::Approve)] |
| 243 | #[case::request_changes(Verdict::RequestChanges)] |
| 244 | #[case::comment(Verdict::Comment)] |
| 245 | // @relation(model.review, meta-ref.typed-tree, scope=function, role=Verifies) |
| 246 | fn review_round_trips_with_every_verdict(#[case] verdict: Verdict) { |
| 247 | let target = |
| 248 | ObjectId::from_hex(b"0123456789abcdef0123456789abcdef01234567").expect("valid hex"); |
| 249 | let review = Review::new(target, verdict, "reviewed the change"); |
| 250 | let (root, store) = serialize(&review).expect("serialize"); |
| 251 | let back: Review = deserialize(&root, &store).expect("deserialize"); |
| 252 | assert_eq!(back, review); |
| 253 | assert_eq!(back.target(), target); |
| 254 | } |
| 255 | |
| 256 | #[rstest] |
| 257 | // @relation(model.review, scope=function, role=Verifies) |
| 258 | fn target_accessor_reflects_the_stored_bytes() { |
| 259 | let target = |
| 260 | ObjectId::from_hex(b"fedcba9876543210fedcba9876543210fedcba98").expect("valid hex"); |
| 261 | let review = Review::new(target, Verdict::Approve, ""); |
| 262 | assert_eq!(review.target(), target); |
| 263 | } |
| 264 | |
| 265 | /// The exact shape a `Review` tree had before [`ReviewState`] existed — |
| 266 | /// `target`/`verdict`/`body` only, no `state` entry at all. Local to |
| 267 | /// this test: it stands in for every `refs/meta/reviews/<target>/*` |
| 268 | /// tree already recorded in a real repository before this change |
| 269 | /// landed. |
| 270 | #[derive(Facet)] |
| 271 | struct PreStateReview { |
| 272 | target: [u8; 20], |
| 273 | verdict: Verdict, |
| 274 | body: String, |
| 275 | } |
| 276 | |
| 277 | #[rstest] |
| 278 | // @relation(model.review, meta-ref.typed-tree, scope=function, role=Verifies) |
| 279 | fn a_review_tree_written_before_state_existed_reads_back_as_active() { |
| 280 | let target = |
| 281 | ObjectId::from_hex(b"0123456789abcdef0123456789abcdef01234567").expect("valid hex"); |
| 282 | let mut bytes = [0u8; 20]; |
| 283 | bytes.copy_from_slice(target.as_slice()); |
| 284 | let legacy = PreStateReview { |
| 285 | target: bytes, |
| 286 | verdict: Verdict::RequestChanges, |
| 287 | body: "reviewed before withdrawal existed".to_owned(), |
| 288 | }; |
| 289 | let (root, store) = serialize(&legacy).expect("serialize the pre-state shape"); |
| 290 | let back: Review = |
| 291 | deserialize(&root, &store).expect("today's Review must still decode a tree with no \ |
| 292 | state entry"); |
| 293 | assert_eq!(back.state, ReviewState::Active); |
| 294 | assert_eq!(back.verdict, Verdict::RequestChanges); |
| 295 | assert_eq!(back.body, "reviewed before withdrawal existed"); |
| 296 | assert_eq!(back.target(), target); |
| 297 | } |
| 298 | |
| 299 | #[rstest] |
| 300 | // @relation(model.review, scope=function, role=Verifies) |
| 301 | fn withdrawn_preserves_target_verdict_and_body_and_flips_only_state() { |
| 302 | let target = |
| 303 | ObjectId::from_hex(b"0123456789abcdef0123456789abcdef01234567").expect("valid hex"); |
| 304 | let review = Review::new(target, Verdict::Approve, "looks good"); |
| 305 | let withdrawn = review.withdrawn(); |
| 306 | |
| 307 | assert_eq!(withdrawn.state, ReviewState::Withdrawn); |
| 308 | assert_eq!(withdrawn.verdict, review.verdict); |
| 309 | assert_eq!(withdrawn.body, review.body); |
| 310 | assert_eq!(withdrawn.target(), review.target()); |
| 311 | |
| 312 | // Idempotent-friendly: withdrawing an already-withdrawn review is a |
| 313 | // no-op-ish re-write, not an error or a second distinct shape. |
| 314 | let withdrawn_again = withdrawn.withdrawn(); |
| 315 | assert_eq!(withdrawn_again, withdrawn); |
| 316 | } |
| 317 | |
| 318 | #[rstest] |
| 319 | #[case::active("active", ReviewState::Active)] |
| 320 | #[case::withdrawn("withdrawn", ReviewState::Withdrawn)] |
| 321 | // @relation(model.review, scope=function, role=Verifies) |
| 322 | fn review_state_parses_its_own_display_strings( |
| 323 | #[case] text: &str, |
| 324 | #[case] expected: ReviewState, |
| 325 | ) { |
| 326 | let parsed: ReviewState = text.parse().expect("known state"); |
| 327 | assert_eq!(parsed, expected); |
| 328 | assert_eq!(parsed.to_string(), text); |
| 329 | } |
| 330 | |
| 331 | #[rstest] |
| 332 | // @relation(model.review, scope=function, role=Verifies) |
| 333 | fn review_state_rejects_an_unknown_string() { |
| 334 | "revoked" |
| 335 | .parse::<ReviewState>() |
| 336 | .expect_err("not a known review state"); |
| 337 | } |
| 338 | } |