git-ents.gitmain
⌘K
foforge
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.

model.review, model.review-pin, model.issue, model.comment-context

Assisted-by: Claude:claude-sonnet-5

Joseph D. Carpinelli · 1 month ago

Reviews

No reviews of this commit yet — record a verdict below.

Start a review

verdict

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.rs → crates/forge/ents-forge/src/issue/entity.rs
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;