model: add the Review entity and extend Issue's command layer
commit d3e4011
model: add the Review entity and extend Issue's command layer
Review lands as a review/ sub-module mirroring comment/'s
entity/command/cli split: the entity stores the reviewed commit id as a
plain [u8; 20] field (matching ents_anchor::Anchor’s own shape) plus a
verdict and body, never a stored list of its comments. review::new
writes both refs model.review requires in two sequential proposals -
the entity ref via propose_entity and the retention pin via
propose_pin(retain = reviewed commit) - sharing one locally generated
id; review::show reuses comment::thread rather than a second
aggregation query. Re-review/update is deferred: only new/list/show
ship this round.
Issue moves from a bare entity.rs to the same three-file module shape,
gaining a command layer (new/edit/list/show) so a CLI or future
web/LSP frontend calls library functions rather than reimplementing
mutation logic (lens.parity). No legacy-read fallback is added to
either entity per current project direction: Review has no history to
migrate, and Issue’s struct shape is unchanged from phase-6.
No reviews of this commit yet — record a verdict below.
Start a review
crates/forge/ents-forge/src/lib.rs
@@ -1,9 +1,10 @@
-//! The forge domain: the [`Issue`] and [`comment::Comment`] entities, and
-//! the `comment` command's business logic — kernel-independent, unlike
-//! `ents-model`'s remaining entities, because a comment command needs
-//! `ents-anchor` (to capture and project a code anchor) and
-//! `ents-receive` (to propose the mutation), neither of which a purely
-//! declarative vocabulary crate like `ents-model` may depend on.
+//! The forge domain: the [`Issue`], [`comment::Comment`], and
+//! [`review::Review`] entities, and the command business logic driving
+//! each — kernel-independent, unlike `ents-model`'s remaining entities,
+//! because a comment or review command needs `ents-anchor` (to capture and
+//! project a code anchor) and `ents-receive` (to propose the mutation),
+//! neither of which a purely declarative vocabulary crate like
+//! `ents-model` may depend on.
//!
//! This crate sits *above* the kernel in the dependency graph, not inside
//! it: `ents-model`, `ents-anchor`, `ents-gate`, `ents-query`,
@@ -11,7 +12,7 @@
//! never depend on `ents-forge` (verified by `grep -rn ents-forge
//! crates/kernel crates/substrate` finding nothing) — `ents-forge` depends
//! on them, never the reverse. `git-ents` (the CLI) depends on this crate
-//! and mounts its comment command through a thin wrapper that only adds
+//! and mounts each command through a thin wrapper that only adds
//! signer/actor construction and CLI-facing error rendering
//! (`crate::mutate::outcome_to_result` on the CLI side).
//!
@@ -19,18 +20,21 @@
//!
//! From `docs/spec/model.adoc` and `docs/spec/meta-ref.adoc`:
//!
-//! - `model.issue` — [`Issue`].
+//! - `model.issue` — [`Issue`] and the command layer around it
+//! ([`issue::new`], [`issue::edit`], [`issue::list`], [`issue::show`]).
//! - `model.comment`, `model.comment-state`, `model.comment-context`,
//! `model.comment-thread` — [`comment::Comment`] and the command layer
//! around it ([`comment::add`], [`comment::reply`],
//! [`comment::resolve`]/[`comment::reopen`], [`comment::thread`]).
-//! - `meta-ref.migration` — pre-broadening comment trees still read back
-//! through the legacy fallback, and any mutation rewrites them under
-//! the current struct on top of the old tip.
-//! - `meta-ref.granularity` — one ref per issue/comment
-//! (`refs/meta/issues/<id>`, `refs/meta/comments/<id>`); see
-//! [`comment::add`] for how a comment's id is generated locally rather
-//! than derived from the entity itself.
+//! - `model.review`, `model.review-pin` — [`review::Review`] and
+//! [`review::new`] (writes both the entity ref and the retention pin),
+//! [`review::list`], [`review::show`] (reusing [`comment::thread`] for
+//! the review's discussion rather than a second aggregation).
+//! - `meta-ref.granularity` — one ref per issue/comment/review
+//! (`refs/meta/issues/<id>`, `refs/meta/comments/<id>`,
+//! `refs/meta/reviews/<id>`); see [`comment::add`] and [`review::new`]
+//! for how an id is generated locally rather than derived from the
+//! entity itself.
//! - `meta-ref.typed-tree` — every entity module's round-trip test.
//! - `anchor.definition`, `anchor.projection`, `anchor.working-tree` —
//! [`comment::add`], [`comment::show`], and [`comment::list_projected`],
@@ -81,9 +85,10 @@
//! ```
mod error;
-mod issue;
pub mod comment;
+pub mod issue;
+pub mod review;
pub use error::{Error, Result};
pub use issue::Issue;
@@ -95,13 +100,14 @@
use super::*;
- /// The two entities that moved from `ents-model` to this crate keep the
- /// same `model.extensibility` guarantee `ents_model`'s own shape test
- /// pins for its remaining entities: each type's reflected
- /// [`facet::Shape::type_identifier`] is exactly its Rust struct name.
+ /// Every entity this crate owns keeps the same `model.extensibility`
+ /// guarantee `ents_model`'s own shape test pins for its remaining
+ /// entities: each type's reflected [`facet::Shape::type_identifier`]
+ /// is exactly its Rust struct name.
#[rstest]
#[case::comment(comment::Comment::SHAPE.type_identifier, "Comment")]
#[case::issue(Issue::SHAPE.type_identifier, "Issue")]
+ #[case::review(review::Review::SHAPE.type_identifier, "Review")]
// @relation(model.extensibility, scope=function, role=Verifies)
fn every_entity_shape_name_tracks_its_struct_declaration(
#[case] reflected: &str,
crates/forge/ents-forge/src/issue/cli.rs
@@ -1,0 +1,73 @@
+//! `git ents issue`'s argument grammar — `figue` derive definitions only.
+//!
+//! Per this project's engineering conventions, this module carries no
+//! logic: every doc comment here becomes `--help` text, and `git-ents`'s
+//! own `exe` module is the only place an [`IssueAction`] variant is
+//! interpreted.
+
+use std::path::PathBuf;
+
+use facet::Facet;
+use figue as args;
+
+/// `git ents issue` actions.
+#[derive(Facet)]
+#[repr(u8)]
+pub enum IssueAction {
+ /// List the issues recorded in this repository.
+ List,
+ /// Show one issue.
+ Show {
+ /// The issue's id.
+ #[facet(args::positional)]
+ id: String,
+ },
+ /// Create an issue. With --title omitted, opens $GIT_EDITOR/$EDITOR on
+ /// a scratch file: its first line becomes the title, the remaining
+ /// lines become the body, lines starting with '#' are stripped, and
+ /// leaving the title empty aborts the command.
+ New {
+ /// The issue's title; omit to compose it (and the body) in an
+ /// editor instead.
+ #[facet(args::named)]
+ title: Option<String>,
+ /// The issue's body; ignored (and instead composed in the editor)
+ /// when --title is omitted.
+ #[facet(args::named)]
+ body: Option<String>,
+ /// The issue's initial state.
+ #[facet(args::named, default = "open")]
+ state: String,
+ /// Labels to attach (repeatable).
+ #[facet(args::named, args::label = "LABEL", default)]
+ label: Vec<String>,
+ /// Members to assign (repeatable).
+ #[facet(args::named, args::label = "USERNAME", default)]
+ assignee: Vec<String>,
+ /// Key to sign with; defaults to `user.signingkey`.
+ #[facet(args::named)]
+ key: Option<PathBuf>,
+ },
+ /// Edit an existing issue's state, assignees, and/or labels.
+ /// Assignees/labels replace the previous set entirely when given at
+ /// least one value; omit an option to leave that field unchanged.
+ Edit {
+ /// The issue to edit.
+ #[facet(args::positional)]
+ id: String,
+ /// Replace the issue's state.
+ #[facet(args::named)]
+ state: Option<String>,
+ /// Replace the issue's labels (repeatable; give at least one to
+ /// replace the set).
+ #[facet(args::named, args::label = "LABEL", default)]
+ label: Vec<String>,
+ /// Replace the issue's assignees (repeatable; give at least one to
+ /// replace the set).
+ #[facet(args::named, args::label = "USERNAME", default)]
+ assignee: Vec<String>,
+ /// Key to sign with; defaults to `user.signingkey`.
+ #[facet(args::named)]
+ key: Option<PathBuf>,
+ },
+}
crates/forge/ents-forge/src/issue/command.rs
@@ -1,0 +1,215 @@
+//! The `issue` command's business logic: create an issue
+//! (`model.issue`), edit its state/assignees/labels, list, and read one
+//! back.
+//!
+//! Generalized over the same trait-object/generic seam
+//! `crate::comment::command` uses (`&dyn RefStore`/`RefStoreRead`,
+//! `impl Find`/`Find + Write`, `&dyn ents_receive::EventSink`), so a
+//! composition root wires the concrete types and calls these functions,
+//! never the other way around (`lens.parity`). Obtaining a title/body from
+//! an interactive editor is a frontend concern — the CLI's own
+//! `commands::issue` resolves that before calling [`new`] here — not an
+//! operation this crate's library layer offers, mirroring how
+//! `crate::comment::command` never touches a terminal either.
+
+use ents_model::MemberId;
+use ents_receive::{Identity, Mode, Outcome, propose_entity};
+use gix_hash::ObjectId;
+use gix_object::{CommitRef, Find, Kind};
+
+use super::Issue;
+use crate::error::{Error, Result};
+
+/// The tree of the commit at `oid` — duplicated from
+/// `crate::comment::command`'s own copy; see that copy's doc for why this
+/// codebase accepts one small copy per module rather than a shared helper.
+fn commit_tree(objects: &impl Find, oid: ObjectId) -> Result<ObjectId> {
+ let mut buf = Vec::new();
+ let data = objects
+ .try_find(&oid, &mut buf)
+ .map_err(|source| Error::InvalidArgument(source.to_string()))?
+ .ok_or_else(|| Error::NotFound {
+ what: oid.to_string(),
+ })?;
+ if data.kind != Kind::Commit {
+ return Err(Error::NotFound {
+ what: oid.to_string(),
+ });
+ }
+ let commit = CommitRef::from_bytes(data.data, oid.kind())
+ .map_err(|source| Error::InvalidArgument(source.to_string()))?;
+ Ok(commit.tree())
+}
+
+/// Read the [`Issue`] at `id`'s ref tip, or [`Error::NotFound`] when no
+/// such ref exists.
+fn issue_at(
+ refs: &dyn gix_ref_store::RefStoreRead,
+ objects: &impl Find,
+ id: &str,
+) -> Result<Issue> {
+ let ref_name = ents_model::namespace::issue_ref(id)?;
+ let Some(tip) = refs.get(ref_name.as_ref())? else {
+ return Err(Error::NotFound {
+ what: format!("issue {id}"),
+ });
+ };
+ let tree = commit_tree(objects, tip)?;
+ Ok(facet_git_tree::deserialize(&tree, objects)?)
+}
+
+/// What `git ents issue new` writes.
+#[derive(Debug, Clone)]
+pub struct NewIssue {
+ /// The issue's title.
+ pub title: String,
+ /// The issue's body.
+ pub body: String,
+ /// The issue's initial state; a new issue's state has no platform
+ /// default (`model.issue`: custom states are schema, not a platform
+ /// feature) — the CLI's own default is `"open"`.
+ pub state: String,
+ /// Members assigned to the issue at creation.
+ pub assignees: Vec<MemberId>,
+ /// Labels attached at creation.
+ pub labels: Vec<String>,
+}
+
+/// `git ents issue new`: create an issue at a freshly generated
+/// `refs/meta/issues/<id>`.
+///
+/// # Errors
+///
+/// Propagates serialization or `receive` failures.
+// @relation(model.issue, lens.parity, scope=function)
+pub fn new(
+ refs: &dyn gix_ref_store::RefStore,
+ objects: &(impl Find + gix_object::Write),
+ events: &dyn ents_receive::EventSink,
+ new: NewIssue,
+ identity: &Identity<'_>,
+ mode: Mode,
+) -> Result<(String, Outcome)> {
+ let issue = Issue {
+ title: new.title,
+ body: new.body,
+ state: new.state,
+ assignees: new.assignees,
+ labels: new.labels,
+ };
+ let id = uuid::Uuid::new_v4().simple().to_string();
+ let ref_name = ents_model::namespace::issue_ref(&id)?;
+ let outcome = propose_entity(
+ refs,
+ objects,
+ events,
+ ref_name,
+ &issue,
+ identity,
+ &format!("Open issue: {}", issue.title),
+ mode,
+ )?;
+ Ok((id, outcome))
+}
+
+/// What `git ents issue edit` changes; a field left `None` is left
+/// untouched.
+#[derive(Debug, Clone, Default)]
+pub struct EditIssue {
+ /// Replace the issue's state, or leave it unchanged.
+ pub state: Option<String>,
+ /// Replace the issue's assignees, or leave them unchanged.
+ pub assignees: Option<Vec<MemberId>>,
+ /// Replace the issue's labels, or leave them unchanged.
+ pub labels: Option<Vec<String>>,
+}
+
+/// `git ents issue edit`: mutate `id`'s state, assignees, and/or labels as
+/// an ordinary mutation commit on the issue's own ref, on top of its
+/// current tip.
+///
+/// # Errors
+///
+/// [`Error::NotFound`] if `id` has no issue ref; otherwise propagates
+/// serialization or `receive` failures.
+// @relation(model.issue, lens.parity, scope=function)
+pub fn edit(
+ refs: &dyn gix_ref_store::RefStore,
+ objects: &(impl Find + gix_object::Write),
+ events: &dyn ents_receive::EventSink,
+ id: &str,
+ edit: EditIssue,
+ identity: &Identity<'_>,
+ mode: Mode,
+) -> Result<Outcome> {
+ let mut issue = issue_at(refs, objects, id)?;
+ if let Some(state) = edit.state {
+ issue.state = state;
+ }
+ if let Some(assignees) = edit.assignees {
+ issue.assignees = assignees;
+ }
+ if let Some(labels) = edit.labels {
+ issue.labels = labels;
+ }
+ let ref_name = ents_model::namespace::issue_ref(id)?;
+ Ok(propose_entity(
+ refs,
+ objects,
+ events,
+ ref_name,
+ &issue,
+ identity,
+ &format!("Edit issue {id}"),
+ mode,
+ )?)
+}
+
+/// `git ents issue list`: every issue recorded in this repository.
+///
+/// # Errors
+///
+/// Propagates a ref-store or object read failure.
+///
+/// # Examples
+///
+/// ```
+/// use ents_forge::issue::list;
+/// use ents_testutil::{MemRefStore, ObjectStore};
+///
+/// let refs = MemRefStore::default();
+/// let objects = ObjectStore::default();
+/// assert!(list(&refs, &objects).expect("reads").is_empty());
+/// ```
+pub fn list(
+ refs: &dyn gix_ref_store::RefStoreRead,
+ objects: &impl Find,
+) -> Result<Vec<(String, Issue)>> {
+ let mut out = Vec::new();
+ for entry in refs.iter_prefix("refs/meta/issues/")? {
+ let (name, tip) = entry?;
+ let path = name.as_bstr().to_string();
+ let Some(id) = path.strip_prefix("refs/meta/issues/") else {
+ continue;
+ };
+ let tree = commit_tree(objects, tip)?;
+ if let Ok(issue) = facet_git_tree::deserialize::<Issue>(&tree, objects) {
+ out.push((id.to_owned(), issue));
+ }
+ }
+ Ok(out)
+}
+
+/// `git ents issue show`: `id`'s issue.
+///
+/// # Errors
+///
+/// [`Error::NotFound`] if `id` has no issue ref.
+// @relation(model.issue, lens.parity, scope=function)
+pub fn show(
+ refs: &dyn gix_ref_store::RefStoreRead,
+ objects: &impl Find,
+ id: &str,
+) -> Result<Issue> {
+ issue_at(refs, objects, id)
+}
crates/forge/ents-forge/src/issue/mod.rs
@@ -1,0 +1,14 @@
+//! The issue sub-domain: the [`Issue`] entity ([`entity`]), the `issue`
+//! command's business logic ([`command`]), and the `issue` subcommand's
+//! argument grammar ([`cli`]) — the same three-file split
+//! [`crate::comment`] and [`crate::review`] use, for the same reason: the
+//! data shape, the command mechanism, and the CLI grammar stay easy to
+//! read independently.
+
+mod cli;
+mod command;
+mod entity;
+
+pub use cli::IssueAction;
+pub use command::{EditIssue, NewIssue, edit, list, new, show};
+pub use entity::Issue;
crates/forge/ents-forge/src/review/cli.rs
@@ -1,0 +1,49 @@
+//! `git ents review`'s argument grammar — `figue` derive definitions only.
+//!
+//! Per this project's engineering conventions, this module carries no
+//! logic: every doc comment here becomes `--help` text, and `git-ents`'s
+//! own `exe` module is the only place a [`ReviewAction`] variant is
+//! interpreted.
+
+use std::path::PathBuf;
+
+use facet::Facet;
+use figue as args;
+
+/// `git ents review` actions.
+#[derive(Facet)]
+#[repr(u8)]
+pub enum ReviewAction {
+ /// Review a commit: a verdict plus a body, occupying two refs — the
+ /// review's own entity ref at refs/meta/reviews/<id>, and a retention
+ /// pin at refs/meta/pins/reviews/<id> keeping the reviewed commit (and
+ /// its ancestry) reachable.
+ New {
+ /// Revision to review.
+ #[facet(args::named, default = "HEAD")]
+ target: String,
+ /// The review's verdict, e.g. approve or request-changes; custom
+ /// values are schema, not a platform feature.
+ #[facet(args::named)]
+ verdict: String,
+ /// The review's body text.
+ #[facet(args::named)]
+ body: String,
+ /// Key to sign with; defaults to `user.signingkey`.
+ #[facet(args::named)]
+ key: Option<PathBuf>,
+ },
+ /// List the reviews recorded in this repository.
+ List {
+ /// Keep only reviews of this revision.
+ #[facet(args::named)]
+ target: Option<String>,
+ },
+ /// Show one review: its reviewed commit, verdict, body, and discussion
+ /// thread (comments naming it as their context).
+ Show {
+ /// The review's id.
+ #[facet(args::positional)]
+ id: String,
+ },
+}
crates/forge/ents-forge/src/review/command.rs
@@ -1,0 +1,213 @@
+//! The `review` command's business logic: review a commit (`model.review`),
+//! writing both the review's own entity ref and its retention pin
+//! (`model.review-pin`), list and read reviews back, and surface a
+//! review's discussion thread by reusing [`crate::comment::thread`] rather
+//! than duplicating context aggregation.
+//!
+//! Generalized over the same trait-object/generic seam
+//! `crate::comment::command` uses (`&dyn RefStore`/`RefStoreRead`,
+//! `impl Find`/`Find + Write`, `&dyn ents_receive::EventSink`) so a
+//! composition root wires the concrete types and calls these functions,
+//! never the other way around (`lens.parity`).
+
+use ents_receive::{Identity, Mode, Outcome, propose_entity, propose_pin};
+use gix_hash::ObjectId;
+use gix_object::{CommitRef, Find, Kind, Write};
+use gix_ref_store::{RefStore, RefStoreRead};
+
+use super::Review;
+use crate::comment::Comment;
+use crate::error::{Error, Result};
+
+/// The tree of the commit at `oid` — duplicated from
+/// `crate::comment::command`'s own copy of this helper (itself duplicated
+/// from `ents_effect::run` and the CLI's `crate::commands::commit_tree`):
+/// three ~15-line copies of "read a commit's tree oid via `Find`" is the
+/// accepted pattern this codebase's own comment-command doc names, and
+/// `review` and `comment` are sibling modules under this crate rather than
+/// one importing the other's private helper, so this is a fourth.
+fn commit_tree(objects: &impl Find, oid: ObjectId) -> Result<ObjectId> {
+ let mut buf = Vec::new();
+ let data = objects
+ .try_find(&oid, &mut buf)
+ .map_err(|source| Error::InvalidArgument(source.to_string()))?
+ .ok_or_else(|| Error::NotFound {
+ what: oid.to_string(),
+ })?;
+ if data.kind != Kind::Commit {
+ return Err(Error::NotFound {
+ what: oid.to_string(),
+ });
+ }
+ let commit = CommitRef::from_bytes(data.data, oid.kind())
+ .map_err(|source| Error::InvalidArgument(source.to_string()))?;
+ Ok(commit.tree())
+}
+
+/// Resolve `rev` (a hex id, ref name, or revspec) to the commit it names in
+/// the repository at `repo_path`.
+fn resolve_commit(repo_path: &std::path::Path, rev: &str) -> Result<ObjectId> {
+ let repo = gix::open(repo_path)?;
+ let resolve = || Error::InvalidArgument(format!("cannot resolve {rev} to a commit"));
+ let id = repo
+ .rev_parse_single(rev)
+ .map_err(|_source| resolve())?
+ .object()
+ .map_err(|_source| resolve())?
+ .peel_to_kind(gix::object::Kind::Commit)
+ .map_err(|_source| resolve())?
+ .id;
+ Ok(id)
+}
+
+/// Read the [`Review`] at `id`'s ref tip, or [`Error::NotFound`] when no
+/// such ref exists.
+fn review_at(refs: &dyn RefStoreRead, objects: &impl Find, id: &str) -> Result<Review> {
+ let ref_name = ents_model::namespace::review_ref(id)?;
+ let Some(tip) = refs.get(ref_name.as_ref())? else {
+ return Err(Error::NotFound {
+ what: format!("review {id}"),
+ });
+ };
+ let tree = commit_tree(objects, tip)?;
+ Ok(facet_git_tree::deserialize(&tree, objects)?)
+}
+
+/// What `git ents review new` writes: the revision to review, its verdict,
+/// and its body.
+#[derive(Debug, Clone)]
+pub struct NewReview {
+ /// The revision to review; resolved to a commit before writing.
+ pub target: String,
+ /// The review's verdict (`approve`, `request-changes`, or any custom
+ /// value — `model.extensibility`).
+ pub verdict: String,
+ /// The review's body text.
+ pub body: String,
+}
+
+/// `git ents review new`: review `new.target`, writing both refs
+/// `model.review` requires — the review's own entity ref at
+/// `refs/meta/reviews/<id>`, and the retention pin at
+/// `refs/meta/pins/reviews/<id>` keeping the reviewed commit (and its
+/// ancestry) reachable (`model.review-pin`) — under one locally generated
+/// id shared by both.
+///
+/// The two refs are written as two separate proposals to [`crate::comment::add`]'s
+/// sibling primitives, [`propose_entity`] and [`propose_pin`]: `receive`
+/// applies one `Proposal`'s transitions atomically, but a `Proposal` is not
+/// itself parameterized to mix an entity-tree transition with an
+/// empty-tree pin transition in one call, so this command reaches two
+/// separate, sequential outcomes rather than one atomic batch — this is
+/// the two-proposals shape the model accepts (`model.review`,
+/// `model.review-pin`), not a gap to close.
+///
+/// # Errors
+///
+/// [`Error::InvalidArgument`] if `new.target` does not resolve to a commit;
+/// otherwise propagates serialization or `receive` failures.
+// @relation(model.review, model.review-pin, lens.parity, scope=function)
+pub fn new(
+ refs: &dyn RefStore,
+ objects: &(impl Find + Write),
+ events: &dyn ents_receive::EventSink,
+ repo_path: &std::path::Path,
+ new: NewReview,
+ identity: &Identity<'_>,
+ mode: Mode,
+) -> Result<(String, Outcome, Outcome)> {
+ let reviewed = resolve_commit(repo_path, &new.target)?;
+ let review = Review::new(reviewed, new.verdict, new.body);
+
+ // The review's id is derived locally, once, and shared by both refs
+ // (`meta-ref.granularity`: one ref per review, one ref per pin) —
+ // mirroring `crate::comment::command::add`'s own locally generated id.
+ let id = uuid::Uuid::new_v4().simple().to_string();
+
+ let entity_ref = ents_model::namespace::review_ref(&id)?;
+ let entity_outcome = propose_entity(
+ refs,
+ objects,
+ events,
+ entity_ref,
+ &review,
+ identity,
+ &format!("Review {reviewed}"),
+ mode,
+ )?;
+
+ let pin_ref = ents_model::namespace::review_pin_ref(&id)?;
+ let pin_outcome = propose_pin(
+ refs,
+ objects,
+ events,
+ pin_ref,
+ reviewed,
+ identity,
+ &format!("Pin review {id}"),
+ mode,
+ )?;
+
+ Ok((id, entity_outcome, pin_outcome))
+}
+
+/// `git ents review list [--target rev]`: every review recorded in this
+/// repository, optionally filtered to those whose most recently reviewed
+/// commit ([`Review::commit`]) resolves to `target`.
+///
+/// # Errors
+///
+/// [`Error::InvalidArgument`] if `target` is given but does not resolve;
+/// otherwise propagates a ref-store or object read failure.
+// @relation(model.review, scope=function)
+pub fn list(
+ refs: &dyn RefStoreRead,
+ objects: &impl Find,
+ repo_path: &std::path::Path,
+ target: Option<&str>,
+) -> Result<Vec<(String, Review)>> {
+ let target_oid = target
+ .map(|rev| resolve_commit(repo_path, rev))
+ .transpose()?;
+ let mut out = Vec::new();
+ for entry in refs.iter_prefix("refs/meta/reviews/")? {
+ let (name, tip) = entry?;
+ let path = name.as_bstr().to_string();
+ let Some(id) = path.strip_prefix("refs/meta/reviews/") else {
+ continue;
+ };
+ let tree = commit_tree(objects, tip)?;
+ let Ok(review) = facet_git_tree::deserialize::<Review>(&tree, objects) else {
+ continue;
+ };
+ if let Some(target_oid) = target_oid
+ && review.commit() != target_oid
+ {
+ continue;
+ }
+ out.push((id.to_owned(), review));
+ }
+ Ok(out)
+}
+
+/// `git ents review show`: `id`'s review, plus its discussion thread —
+/// every [`Comment`] naming `reviews/<id>` as its context (or a reply into
+/// one), reusing [`crate::comment::thread`] rather than a second
+/// aggregation query (`model.comment-context`, `model.review`: "the review
+/// itself MUST NOT store a list of its comments").
+///
+/// # Errors
+///
+/// [`Error::NotFound`] if `id` has no review ref; otherwise propagates a
+/// ref-store or object read failure.
+// @relation(model.review, model.comment-context, lens.parity, scope=function)
+pub fn show(
+ refs: &dyn RefStoreRead,
+ objects: &impl Find,
+ id: &str,
+) -> Result<(Review, Vec<(String, Comment)>)> {
+ let review = review_at(refs, objects, id)?;
+ let context = format!("reviews/{id}");
+ let thread = crate::comment::thread(refs, objects, &context)?;
+ Ok((review, thread))
+}
crates/forge/ents-forge/src/review/entity.rs
@@ -1,0 +1,111 @@
+//! The Review entity: a verdict plus a context — the id of the most
+//! recently reviewed commit, a verdict, and a body.
+//!
+//! Spec coverage: `model.review`.
+
+use facet::Facet;
+use gix_hash::ObjectId;
+
+/// A verdict on a commit, plus a body (`model.review`).
+///
+/// Every review occupies exactly two refs: this entity's own tree at
+/// `refs/meta/reviews/<id>`, and a retention pin at
+/// `refs/meta/pins/reviews/<id>` anchoring the reviewed content itself
+/// (`model.review-pin`) — [`super::new`] writes both. `commit` is the id
+/// of the most recently reviewed commit, stored as a plain data field the
+/// same way [`ents_anchor::Anchor::commit`] stores its own commit: a
+/// `[u8; 20]` field plus a [`Review::commit`] accessor, so reading it back
+/// never requires the pin ref — the pin anchors reachability, the entity
+/// describes what was reviewed. `approve` and `request-changes` are
+/// conventions, not an enum: custom verdicts are schema, not a platform
+/// feature (`model.extensibility`), exactly as custom states are for
+/// [`crate::Issue`] and [`crate::comment::Comment`]. Reviewer and
+/// timestamp come from the mutation commit chain rather than a stored
+/// field (`meta-ref.trailers`), so `Review` carries no author or
+/// timestamp field — the same omission [`crate::comment::Comment`] makes.
+/// A review's discussion is [`crate::comment::Comment`] entities naming
+/// the review as their context (`model.comment-context`); `Review` itself
+/// stores no list of its comments.
+///
+/// # Examples
+///
+/// ```
+/// use ents_forge::review::Review;
+///
+/// let commit = gix_hash::ObjectId::from_hex(b"0123456789abcdef0123456789abcdef01234567")
+/// .expect("valid hex");
+/// let review = Review::new(commit, "approve", "looks good");
+/// let (root, store) = facet_git_tree::serialize(&review).expect("serialize");
+/// let back: Review = facet_git_tree::deserialize(&root, &store).expect("deserialize");
+/// assert_eq!(back, review);
+/// assert_eq!(back.commit(), commit);
+/// ```
+// @relation(model.review, meta-ref.typed-tree, model.extensibility, scope=file)
+#[derive(Debug, Clone, PartialEq, Eq, Facet)]
+pub struct Review {
+ pub(crate) commit: [u8; 20],
+ /// The review's verdict — `approve`, `request-changes`, or any custom
+ /// value a schema defines; not a fixed enum (`model.review`,
+ /// `model.extensibility`).
+ pub verdict: String,
+ /// The review's body text.
+ pub body: String,
+}
+
+impl Review {
+ /// Build a review of `commit` carrying `verdict` and `body`
+ /// (`model.review`).
+ #[must_use]
+ pub fn new(commit: ObjectId, verdict: impl Into<String>, body: impl Into<String>) -> Self {
+ let mut bytes = [0u8; 20];
+ bytes.copy_from_slice(commit.as_slice());
+ Self {
+ commit: bytes,
+ verdict: verdict.into(),
+ body: body.into(),
+ }
+ }
+
+ /// The id of the most recently reviewed commit (`model.review`):
+ /// reading this never requires the pin ref
+ /// (`refs/meta/pins/reviews/<id>`) — the pin anchors reachability, the
+ /// entity describes what was reviewed.
+ #[must_use]
+ pub fn commit(&self) -> ObjectId {
+ ObjectId::from_bytes_or_panic(&self.commit)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ #![allow(clippy::expect_used, reason = "unit test")]
+
+ use facet_git_tree::{deserialize, serialize};
+ use rstest::rstest;
+
+ use super::*;
+
+ #[rstest]
+ #[case::approve("approve")]
+ #[case::request_changes("request-changes")]
+ #[case::custom_verdict("needs-design-doc")]
+ // @relation(model.review, model.extensibility, meta-ref.typed-tree, scope=function, role=Verifies)
+ fn review_round_trips_with_any_verdict_string(#[case] verdict: &str) {
+ let commit =
+ ObjectId::from_hex(b"0123456789abcdef0123456789abcdef01234567").expect("valid hex");
+ let review = Review::new(commit, verdict, "reviewed the change");
+ let (root, store) = serialize(&review).expect("serialize");
+ let back: Review = deserialize(&root, &store).expect("deserialize");
+ assert_eq!(back, review);
+ assert_eq!(back.commit(), commit);
+ }
+
+ #[rstest]
+ // @relation(model.review, scope=function, role=Verifies)
+ fn commit_accessor_reflects_the_stored_bytes() {
+ let commit =
+ ObjectId::from_hex(b"fedcba9876543210fedcba9876543210fedcba98").expect("valid hex");
+ let review = Review::new(commit, "approve", "");
+ assert_eq!(review.commit(), commit);
+ }
+}
crates/forge/ents-forge/src/review/mod.rs
@@ -1,0 +1,13 @@
+//! The review sub-domain: the [`Review`] entity ([`entity`]), the `review`
+//! command's business logic ([`command`]), and the `review` subcommand's
+//! argument grammar ([`cli`]) — the same three-file split
+//! [`crate::comment`] uses, for the same reason: the data shape, the
+//! command mechanism, and the CLI grammar stay easy to read independently.
+
+mod cli;
+mod command;
+mod entity;
+
+pub use cli::ReviewAction;
+pub use command::{NewReview, list, new, show};
+pub use entity::Review;