crates/forge/ents-forge/src/review/command.rs
command.rshistorycomment on this file
| 1 | //! The `review` command's business logic: review a commit (`model.review`), |
| 2 | //! writing both the review's own entity ref and its retention pin |
| 3 | //! (`model.review-pin`), advancing the same composite-keyed ref on |
| 4 | //! re-review rather than minting a new one, list and read reviews back, |
| 5 | //! and surface a review's discussion thread by reusing |
| 6 | //! [`crate::comment::thread`] rather than duplicating context aggregation. |
| 7 | //! |
| 8 | //! Generalized over the same trait-object/generic seam |
| 9 | //! `crate::comment::command` uses (`&dyn RefStore`/`RefStoreRead`, |
| 10 | //! `impl Find`/`Find + Write`, `&dyn ents_receive::EventSink`) so a |
| 11 | //! composition root wires the concrete types and calls these functions, |
| 12 | //! never the other way around (`lens.parity`). |
| 13 | |
| 14 | use ents_model::MemberId; |
| 15 | use ents_receive::{Identity, Mode, Outcome, propose_entity_with_pin}; |
| 16 | use gix_hash::ObjectId; |
| 17 | use gix_object::{CommitRef, Find, Kind, Write}; |
| 18 | use gix_ref_store::{RefStore, RefStoreRead}; |
| 19 | |
| 20 | use super::Review; |
| 21 | use crate::comment::Comment; |
| 22 | use crate::error::{Error, Result}; |
| 23 | |
| 24 | /// The tree of the commit at `oid` — duplicated from |
| 25 | /// `crate::comment::command`'s own copy of this helper (itself duplicated |
| 26 | /// from `ents_effect::run` and the CLI's `crate::commands::commit_tree`): |
| 27 | /// three ~15-line copies of "read a commit's tree oid via `Find`" is the |
| 28 | /// accepted pattern this codebase's own comment-command doc names, and |
| 29 | /// `review` and `comment` are sibling modules under this crate rather than |
| 30 | /// one importing the other's private helper, so this is a fourth. |
| 31 | fn commit_tree(objects: &impl Find, oid: ObjectId) -> Result<ObjectId> { |
| 32 | let mut buf = Vec::new(); |
| 33 | let data = objects |
| 34 | .try_find(&oid, &mut buf) |
| 35 | .map_err(|source| Error::InvalidArgument(source.to_string()))? |
| 36 | .ok_or_else(|| Error::NotFound { |
| 37 | what: oid.to_string(), |
| 38 | })?; |
| 39 | if data.kind != Kind::Commit { |
| 40 | return Err(Error::NotFound { |
| 41 | what: oid.to_string(), |
| 42 | }); |
| 43 | } |
| 44 | let commit = CommitRef::from_bytes(data.data, oid.kind()) |
| 45 | .map_err(|source| Error::InvalidArgument(source.to_string()))?; |
| 46 | Ok(commit.tree()) |
| 47 | } |
| 48 | |
| 49 | /// Resolve `rev` (a hex id, ref name, or revspec) to the commit it names in |
| 50 | /// the repository at `repo_path`. |
| 51 | fn resolve_commit(repo_path: &std::path::Path, rev: &str) -> Result<ObjectId> { |
| 52 | let repo = gix::open(repo_path)?; |
| 53 | resolve_in(&repo, rev) |
| 54 | } |
| 55 | |
| 56 | /// [`resolve_commit`], against an already-open `repo` — [`new`] already |
| 57 | /// holds one open (to check re-review ancestry), so it resolves its target |
| 58 | /// through this rather than opening the repository a second time. |
| 59 | fn resolve_in(repo: &gix::Repository, rev: &str) -> Result<ObjectId> { |
| 60 | let resolve = || Error::InvalidArgument(format!("cannot resolve {rev} to a commit")); |
| 61 | let id = repo |
| 62 | .rev_parse_single(rev) |
| 63 | .map_err(|_source| resolve())? |
| 64 | .object() |
| 65 | .map_err(|_source| resolve())? |
| 66 | .peel_to_kind(gix::object::Kind::Commit) |
| 67 | .map_err(|_source| resolve())? |
| 68 | .id; |
| 69 | Ok(id) |
| 70 | } |
| 71 | |
| 72 | /// Whether `ancestor` is `descendant` itself, or reachable from it by parent |
| 73 | /// edges — checked via the open repository's own merge-base machinery |
| 74 | /// rather than a bespoke walk. A `false` result on a lookup failure (no |
| 75 | /// shared history at all) is the correct answer, not an error to propagate: |
| 76 | /// it just means this is not the review [`new`] should advance. |
| 77 | fn is_ancestor_or_self(repo: &gix::Repository, ancestor: ObjectId, descendant: ObjectId) -> bool { |
| 78 | ancestor == descendant |
| 79 | || repo |
| 80 | .merge_base(ancestor, descendant) |
| 81 | .is_ok_and(|base| base.detach() == ancestor) |
| 82 | } |
| 83 | |
| 84 | /// This member's existing review, if any, whose recorded target |
| 85 | /// ([`Review::target`]) is `reviewed` itself or one of its ancestors — the |
| 86 | /// fast-forward re-review case `model.review-pin` describes: "re-reviewing |
| 87 | /// after the target moves". Returns the found review's own genesis target |
| 88 | /// segment (the refname's `<target>`, parsed back via |
| 89 | /// [`ents_model::namespace::parse_review_ref`]) for [`new`] to advance the |
| 90 | /// same two refs under, rather than minting fresh ones. |
| 91 | fn find_review_to_advance( |
| 92 | refs: &dyn RefStoreRead, |
| 93 | objects: &impl Find, |
| 94 | repo: &gix::Repository, |
| 95 | member: &MemberId, |
| 96 | reviewed: ObjectId, |
| 97 | ) -> Result<Option<String>> { |
| 98 | for entry in refs.iter_prefix("refs/meta/reviews/")? { |
| 99 | let (name, tip) = entry?; |
| 100 | let Some((target, entry_member)) = ents_model::namespace::parse_review_ref(name.as_ref()) |
| 101 | else { |
| 102 | continue; |
| 103 | }; |
| 104 | if entry_member != *member { |
| 105 | continue; |
| 106 | } |
| 107 | let tree = commit_tree(objects, tip)?; |
| 108 | let Ok(review) = facet_git_tree::deserialize::<Review>(&tree, objects) else { |
| 109 | continue; |
| 110 | }; |
| 111 | if is_ancestor_or_self(repo, review.target(), reviewed) { |
| 112 | return Ok(Some(target)); |
| 113 | } |
| 114 | } |
| 115 | Ok(None) |
| 116 | } |
| 117 | |
| 118 | /// Read the [`Review`] at `target`/`member`'s ref tip, or [`Error::NotFound`] |
| 119 | /// when no such ref exists. |
| 120 | fn review_at( |
| 121 | refs: &dyn RefStoreRead, |
| 122 | objects: &impl Find, |
| 123 | target: &str, |
| 124 | member: &MemberId, |
| 125 | ) -> Result<Review> { |
| 126 | let ref_name = ents_model::namespace::review_ref(target, member)?; |
| 127 | let Some(tip) = refs.get(ref_name.as_ref())? else { |
| 128 | return Err(Error::NotFound { |
| 129 | what: format!("review {target}/{member}"), |
| 130 | }); |
| 131 | }; |
| 132 | let tree = commit_tree(objects, tip)?; |
| 133 | Ok(facet_git_tree::deserialize(&tree, objects)?) |
| 134 | } |
| 135 | |
| 136 | /// What `git ents review new` writes: the revision to review, its verdict, |
| 137 | /// and its body. |
| 138 | #[derive(Debug, Clone)] |
| 139 | pub struct NewReview { |
| 140 | /// The revision to review; resolved to a commit before writing. |
| 141 | pub target: String, |
| 142 | /// The review's verdict. |
| 143 | pub verdict: super::Verdict, |
| 144 | /// The review's body text. |
| 145 | pub body: String, |
| 146 | } |
| 147 | |
| 148 | /// `git ents review new`: review `new.target` as `member`, writing both |
| 149 | /// refs `model.review` requires — the review's own entity ref at |
| 150 | /// `refs/meta/reviews/<target>/<member>`, and the retention pin at |
| 151 | /// `refs/meta/pins/reviews/<target>/<member>` keeping the reviewed commit |
| 152 | /// (and its ancestry) reachable (`model.review-pin`) — a composite natural |
| 153 | /// key, no minted id anywhere (`meta-ref.identity-binding`). |
| 154 | /// |
| 155 | /// When `member` already has a review whose own recorded target |
| 156 | /// ([`Review::target`]) is `new.target` itself or one of its ancestors, |
| 157 | /// this is a re-review: the *same* two refs advance fast-forward under the |
| 158 | /// original genesis target segment, with [`Review::target`] updated to the |
| 159 | /// newly reviewed commit, rather than a fresh pair being minted |
| 160 | /// (`model.review-pin`: "re-reviewing after the target moves MUST advance |
| 161 | /// the pin fast-forward"). Otherwise this is the review's genesis, keyed by |
| 162 | /// the reviewed commit's own oid. |
| 163 | /// |
| 164 | /// The two refs travel in one atomic mutation via |
| 165 | /// [`propose_entity_with_pin`] (`receive.multi-ref-atomicity`): the |
| 166 | /// ref-store's atomic multi-ref compare-and-swap admits or refuses both |
| 167 | /// transitions together, so a review is never left with its entity written |
| 168 | /// but its retention pin missing. One [`Outcome`] covers the whole batch. |
| 169 | /// |
| 170 | /// Returns the composite key's target segment (the review's genesis |
| 171 | /// target, unchanged across re-reviews) alongside the reached [`Outcome`]. |
| 172 | /// |
| 173 | /// # Errors |
| 174 | /// |
| 175 | /// [`Error::InvalidArgument`] if `new.target` does not resolve to a commit; |
| 176 | /// otherwise propagates serialization or `receive` failures. |
| 177 | // @relation(model.review, model.review-pin, meta-ref.identity-binding, receive.multi-ref-atomicity, lens.parity, scope=function) |
| 178 | #[expect( |
| 179 | clippy::too_many_arguments, |
| 180 | reason = "one field per mutation shape (refs, objects, events, repo, the draft, the acting \ |
| 181 | member, identity, mode), mirroring propose_entity_with_pin's identically-justified \ |
| 182 | shape one layer down" |
| 183 | )] |
| 184 | pub fn new( |
| 185 | refs: &dyn RefStore, |
| 186 | objects: &(impl Find + Write), |
| 187 | events: &dyn ents_receive::EventSink, |
| 188 | repo_path: &std::path::Path, |
| 189 | new: NewReview, |
| 190 | member: &MemberId, |
| 191 | identity: &Identity<'_>, |
| 192 | mode: Mode, |
| 193 | ) -> Result<(String, Outcome)> { |
| 194 | let repo = gix::open(repo_path)?; |
| 195 | let reviewed = resolve_in(&repo, &new.target)?; |
| 196 | let review = Review::new(reviewed, new.verdict, new.body); |
| 197 | |
| 198 | // Re-reviewing after the target moves advances the SAME ref rather than |
| 199 | // minting a new one (`model.review-pin`): find this member's existing |
| 200 | // review, if any, whose own recorded target is an ancestor of (or equal |
| 201 | // to) the commit reviewed now, and advance it in place. |
| 202 | let target_hex = find_review_to_advance(refs, objects, &repo, member, reviewed)? |
| 203 | .unwrap_or_else(|| reviewed.to_string()); |
| 204 | |
| 205 | let outcome = propose_entity_with_pin( |
| 206 | refs, |
| 207 | objects, |
| 208 | events, |
| 209 | ents_model::namespace::review_ref(&target_hex, member)?, |
| 210 | &review, |
| 211 | ents_model::namespace::review_pin_ref(&target_hex, member)?, |
| 212 | reviewed, |
| 213 | identity, |
| 214 | &format!("Review {reviewed}"), |
| 215 | &format!("Pin review {target_hex}/{member}"), |
| 216 | mode, |
| 217 | )?; |
| 218 | |
| 219 | Ok((target_hex, outcome)) |
| 220 | } |
| 221 | |
| 222 | /// `git ents review list [--target rev]`: every review recorded in this |
| 223 | /// repository, keyed by its composite `(target, member)` segments |
| 224 | /// (`model.review`), optionally filtered to those whose most recently |
| 225 | /// reviewed commit ([`Review::target`]) resolves to `target`. |
| 226 | /// |
| 227 | /// # Errors |
| 228 | /// |
| 229 | /// [`Error::InvalidArgument`] if `target` is given but does not resolve; |
| 230 | /// otherwise propagates a ref-store or object read failure. |
| 231 | // @relation(model.review, meta-ref.identity-binding, scope=function) |
| 232 | pub fn list( |
| 233 | refs: &dyn RefStoreRead, |
| 234 | objects: &impl Find, |
| 235 | repo_path: &std::path::Path, |
| 236 | target: Option<&str>, |
| 237 | ) -> Result<Vec<((String, MemberId), Review)>> { |
| 238 | let target_oid = target |
| 239 | .map(|rev| resolve_commit(repo_path, rev)) |
| 240 | .transpose()?; |
| 241 | let mut out = Vec::new(); |
| 242 | for entry in refs.iter_prefix("refs/meta/reviews/")? { |
| 243 | let (name, tip) = entry?; |
| 244 | let Some((target_hex, member)) = ents_model::namespace::parse_review_ref(name.as_ref()) |
| 245 | else { |
| 246 | continue; |
| 247 | }; |
| 248 | let tree = commit_tree(objects, tip)?; |
| 249 | let Ok(review) = facet_git_tree::deserialize::<Review>(&tree, objects) else { |
| 250 | continue; |
| 251 | }; |
| 252 | if let Some(target_oid) = target_oid |
| 253 | && review.target() != target_oid |
| 254 | { |
| 255 | continue; |
| 256 | } |
| 257 | out.push(((target_hex, member), review)); |
| 258 | } |
| 259 | Ok(out) |
| 260 | } |
| 261 | |
| 262 | /// `git ents review withdraw`: retract `member`'s own review of `target`, |
| 263 | /// leaving the prior verdict in history rather than erasing it |
| 264 | /// (`model.review`). Resolves `target` (a revision) exactly as [`new`] |
| 265 | /// does, then reuses [`find_review_to_advance`] to locate `member`'s |
| 266 | /// *existing* review whose recorded target ([`Review::target`]) is |
| 267 | /// `target` itself or one of its ancestors — the same fast-forward lookup |
| 268 | /// `new` performs before a re-review, so a withdrawal reaches the review |
| 269 | /// even if it has since advanced past the commit named here. That review's |
| 270 | /// [`Review::withdrawn`] copy — same `target`, `verdict`, and `body`, only |
| 271 | /// `state` flipped — is written back onto the *same* two refs via |
| 272 | /// [`propose_entity_with_pin`], the identical advance/ref-writing path |
| 273 | /// `new` uses: no parallel write path exists for withdrawal |
| 274 | /// (`model.review-pin`, `receive.multi-ref-atomicity`). |
| 275 | /// |
| 276 | /// Ownership is enforced entirely by `ents-gate`'s existing checks on the |
| 277 | /// `refs/meta/reviews/<target>/<member>` namespace — `identity_binding`'s |
| 278 | /// `Namespace::Review` arm (a review must be signed by the exact `member` |
| 279 | /// its own refname names) and `owner_mutation`'s `Namespace::Review` arm |
| 280 | /// (only that same signer may advance it) — so this function does not |
| 281 | /// re-check who `member` is; it only ever builds and writes |
| 282 | /// `reviews/<target>/<member>`, `member`'s own ref, and lets the gate |
| 283 | /// refuse anything else the same way it already refuses a mismatched |
| 284 | /// re-review (`gate.identity-binding`, `gate.owner-mutation`). |
| 285 | /// |
| 286 | /// Withdrawing an already-withdrawn review is not an error: the found |
| 287 | /// review's `withdrawn()` copy of a `Withdrawn` review is itself |
| 288 | /// `Withdrawn`, so this simply re-writes the same state — a harmless |
| 289 | /// no-op-ish advance, not a special case this function detects. |
| 290 | /// |
| 291 | /// # Errors |
| 292 | /// |
| 293 | /// [`Error::InvalidArgument`] if `target` does not resolve to a commit; |
| 294 | /// [`Error::NotFound`] if `member` has no existing review reaching |
| 295 | /// `target` — there is nothing to withdraw; otherwise propagates |
| 296 | /// serialization or `receive` failures. |
| 297 | // @relation(model.review, model.review-pin, meta-ref.identity-binding, receive.multi-ref-atomicity, lens.parity, scope=function) |
| 298 | #[expect( |
| 299 | clippy::too_many_arguments, |
| 300 | reason = "one field per mutation shape, mirroring new's identically-justified shape" |
| 301 | )] |
| 302 | pub fn withdraw( |
| 303 | refs: &dyn RefStore, |
| 304 | objects: &(impl Find + Write), |
| 305 | events: &dyn ents_receive::EventSink, |
| 306 | repo_path: &std::path::Path, |
| 307 | target: &str, |
| 308 | member: &MemberId, |
| 309 | identity: &Identity<'_>, |
| 310 | mode: Mode, |
| 311 | ) -> Result<(String, Outcome)> { |
| 312 | let repo = gix::open(repo_path)?; |
| 313 | let reviewed = resolve_in(&repo, target)?; |
| 314 | |
| 315 | let target_hex = find_review_to_advance(refs, objects, &repo, member, reviewed)?.ok_or_else( |
| 316 | || Error::NotFound { |
| 317 | what: format!("review of {reviewed} by {member}"), |
| 318 | }, |
| 319 | )?; |
| 320 | let existing = review_at(refs, objects, &target_hex, member)?; |
| 321 | let withdrawn = existing.withdrawn(); |
| 322 | let retained = existing.target(); |
| 323 | |
| 324 | let outcome = propose_entity_with_pin( |
| 325 | refs, |
| 326 | objects, |
| 327 | events, |
| 328 | ents_model::namespace::review_ref(&target_hex, member)?, |
| 329 | &withdrawn, |
| 330 | ents_model::namespace::review_pin_ref(&target_hex, member)?, |
| 331 | retained, |
| 332 | identity, |
| 333 | &format!("Withdraw review {retained}"), |
| 334 | &format!("Pin review {target_hex}/{member}"), |
| 335 | mode, |
| 336 | )?; |
| 337 | |
| 338 | Ok((target_hex, outcome)) |
| 339 | } |
| 340 | |
| 341 | /// `git ents review show`: `target`/`member`'s review, plus its discussion |
| 342 | /// thread — every [`Comment`] naming `reviews/<target>/<member>` as its |
| 343 | /// context (or a reply into one), reusing [`crate::comment::thread`] rather |
| 344 | /// than a second aggregation query (`model.comment-context`, `model.review`: |
| 345 | /// "the review itself MUST NOT store a list of its comments"). |
| 346 | /// |
| 347 | /// # Errors |
| 348 | /// |
| 349 | /// [`Error::NotFound`] if `target`/`member` has no review ref; otherwise |
| 350 | /// propagates a ref-store or object read failure. |
| 351 | // @relation(model.review, model.comment-context, lens.parity, scope=function) |
| 352 | pub fn show( |
| 353 | refs: &dyn RefStoreRead, |
| 354 | objects: &impl Find, |
| 355 | target: &str, |
| 356 | member: &MemberId, |
| 357 | ) -> Result<(Review, Vec<(String, Comment)>)> { |
| 358 | let review = review_at(refs, objects, target, member)?; |
| 359 | let context = format!("reviews/{target}/{member}"); |
| 360 | let thread = crate::comment::thread(refs, objects, &context)?; |
| 361 | Ok((review, thread)) |
| 362 | } |