git-ents.gitmain
⌘K
foforge
command.rs362 lines · 14.7 KB · rusthistorycomment 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
14use ents_model::MemberId;
15use ents_receive::{Identity, Mode, Outcome, propose_entity_with_pin};
16use gix_hash::ObjectId;
17use gix_object::{CommitRef, Find, Kind, Write};
18use gix_ref_store::{RefStore, RefStoreRead};
19
20use super::Review;
21use crate::comment::Comment;
22use 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.
31fn 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`.
51fn 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.
59fn 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.
77fn 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.
91fn 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.
120fn 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)]
139pub 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)]
184pub 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)
232pub 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)]
302pub 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)
352pub 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}