git-ents.gitmain
⌘K
foforge
commit d118ff3
roots: add review and extend issue porcelain in git-ents
Wires ents_forge::review and the newly-extended ents_forge::issue into the CLI: cli.rs declares review (new/list/show) and issue (list/show/new/edit) subcommand families, exe.rs dispatches each to a thin commands

wrapper that only resolves the signer/actor identity and renders output, and every operation is the library call itself (lens.parity).

issue new opens $GIT_EDITOR/$EDITOR on a scratch file when --title is omitted, mirroring git commit’s own editor fallback: first line is the title, remaining lines are the body, '#' lines are stripped, and an empty title aborts the command. This editor-invocation stays CLI-only (commands::issue::compose_in_editor) rather than living in ents_forge, since spawning a terminal editor is a frontend concern, not an operation the library layer offers.

Integration tests cover: review new writing both refs with the pin’s parents including the reviewed commit, review show surfacing a context comment, review list filtering by target, issue new with an explicit title, issue new composing a title/body from a fake $EDITOR script run through the real binary, an empty editor message aborting, and issue edit round-tripping state/assignees/labels.

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

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/cli/git-ents/src/cli.rs @@ -10,6 +10,8 @@ use figue::{self as args, FigueBuiltins}; pub use ents_forge::comment::CommentAction; +pub use ents_forge::issue::IssueAction; +pub use ents_forge::review::ReviewAction; pub use ents_kiln::toolchain::ToolchainAction; /// Local root wiring, subcommand surface, and the single-node hosted @@ -91,6 +93,20 @@ #[facet(args::subcommand)] action: CommentAction, }, + /// Manage issues at `refs/meta/issues/<id>`. + Issue { + /// The issue action to run. + #[facet(args::subcommand)] + action: IssueAction, + }, + /// Review a commit: a verdict plus a body at `refs/meta/reviews/<id>`, + /// with a retention pin at `refs/meta/pins/reviews/<id>` keeping the + /// reviewed commit reachable. + Review { + /// The review action to run. + #[facet(args::subcommand)] + action: ReviewAction, + }, /// Work with entities awaiting adoption at /// `refs/meta/inbox/<member>/<id>`. Inbox {
crates/cli/git-ents/src/exe.rs @@ -8,8 +8,8 @@ )] use crate::cli::{ - AccountAction, Cli, CommentAction, EffectAction, HookAction, InboxAction, MembersAction, - RedactAction, ToolchainAction, Top, + AccountAction, Cli, CommentAction, EffectAction, HookAction, InboxAction, IssueAction, + MembersAction, RedactAction, ReviewAction, ToolchainAction, Top, }; use crate::commands; use crate::error::Result; @@ -49,6 +49,8 @@ Top::Effect { action } => run_effect(action, out), Top::Toolchain { action } => run_toolchain(action, out), Top::Comment { action } => run_comment(action, out), + Top::Issue { action } => run_issue(action, out), + Top::Review { action } => run_review(action, out), Top::Inbox { action } => run_inbox(action, out), Top::Redact { action } => run_redact(action, out), Top::Hook { action } => run_hook(action, out), @@ -272,6 +274,91 @@ Ok(()) } +fn run_issue(action: IssueAction, out: &mut impl std::io::Write) -> Result<()> { + let root = LocalRoot::discover(".")?; + match action { + IssueAction::List => { + for (id, issue) in commands::issue::list(&root)? { + let _ = writeln!(out, "{id}\t{}\t{}", issue.state, issue.title); + } + } + IssueAction::Show { id } => { + let issue = commands::issue::show(&root, &id)?; + let _ = writeln!(out, "title: {}", issue.title); + let _ = writeln!(out, "state: {}", issue.state); + if !issue.assignees.is_empty() { + let names: Vec<_> = issue.assignees.iter().map(ToString::to_string).collect(); + let _ = writeln!(out, "assignees: {}", names.join(", ")); + } + if !issue.labels.is_empty() { + let _ = writeln!(out, "labels: {}", issue.labels.join(", ")); + } + let _ = writeln!(out, "body: {}", issue.body); + } + IssueAction::New { + title, + body, + state, + label, + assignee, + key, + } => { + let id = commands::issue::new(&root, title, body, state, label, assignee, key)?; + let _ = writeln!(out, "opened {id}"); + } + IssueAction::Edit { + id, + state, + label, + assignee, + key, + } => { + commands::issue::edit(&root, &id, state, label, assignee, key)?; + let _ = writeln!(out, "edited {id}"); + } + } + Ok(()) +} + +fn run_review(action: ReviewAction, out: &mut impl std::io::Write) -> Result<()> { + let root = LocalRoot::discover(".")?; + match action { + ReviewAction::New { + target, + verdict, + body, + key, + } => { + let new = ents_forge::review::NewReview { + target, + verdict, + body, + }; + let id = commands::review::new(&root, new, key)?; + let _ = writeln!(out, "reviewed {id}"); + } + ReviewAction::List { target } => { + for (id, review) in commands::review::list(&root, target)? { + let _ = writeln!(out, "{id}\t{}\t{}", review.commit(), review.verdict); + } + } + ReviewAction::Show { id } => { + let (review, thread) = commands::review::show(&root, &id)?; + let _ = writeln!(out, "commit: {}", review.commit()); + let _ = writeln!(out, "verdict: {}", review.verdict); + let _ = writeln!(out, "body: {}", review.body); + for (comment_id, comment) in thread { + let _ = writeln!( + out, + "comment {comment_id}\t{}\t{}", + comment.state, comment.body + ); + } + } + } + Ok(()) +} + fn run_inbox(action: InboxAction, out: &mut impl std::io::Write) -> Result<()> { let root = LocalRoot::discover(".")?; match action {
crates/cli/git-ents/tests/cli_help.rs @@ -39,6 +39,8 @@ "effect", "toolchain", "comment", + "issue", + "review", "inbox", "redact", "hook",
crates/cli/git-ents/src/commands/mod.rs @@ -12,8 +12,10 @@ pub mod comment; pub mod effect; pub mod inbox; +pub mod issue; pub mod members; pub mod redact; +pub mod review; pub mod serve; pub mod setup; pub mod toolchain;
crates/cli/git-ents/src/commands/issue.rs @@ -1,0 +1,186 @@ +//! `git ents issue`: a thin wrapper around `ents_forge::issue`'s business +//! logic, plus the one CLI-only piece that operation needs: composing a +//! title and body in `$GIT_EDITOR`/`$EDITOR` when `--title` is omitted +//! (mirroring `git commit`'s own editor fallback) — a frontend concern, +//! not an operation `ents_forge::issue` offers (`lens.parity`). + +use std::io::Write as _; +use std::path::PathBuf; +use std::process::Command; + +use ents_forge::Issue; +use ents_forge::issue::{self, EditIssue, NewIssue}; +use ents_model::MemberId; +use ents_receive::Identity; + +use super::{actor, signer}; +use crate::error::{Error, Result}; +use crate::mutate::outcome_to_result; +use crate::root::LocalRoot; + +/// `git ents issue list`: every issue recorded in this repository. +/// +/// # Errors +/// +/// Propagates a ref-store or object read failure. +pub fn list(root: &LocalRoot) -> Result<Vec<(String, Issue)>> { + Ok(issue::list(&root.refs, &root.objects)?) +} + +/// `git ents issue show`: `id`'s issue. +/// +/// # Errors +/// +/// [`crate::error::Error::Forge`] (wrapping [`ents_forge::Error::NotFound`]) +/// if `id` has no issue ref. +pub fn show(root: &LocalRoot, id: &str) -> Result<Issue> { + Ok(issue::show(&root.refs, &root.objects, id)?) +} + +/// `git ents issue new`: create an issue. When `title` is `None`, composes +/// the title and body interactively (see [`compose_in_editor`]). +/// +/// # Errors +/// +/// [`Error::InvalidArgument`] if no title was given and the interactively +/// composed message is empty (the editor path aborts, mirroring `git +/// commit`'s own empty-message abort); [`Error::Io`] if the editor cannot +/// be spawned or the scratch file cannot be read or written; otherwise see +/// [`crate::mutate::outcome_to_result`]. +pub fn new( + root: &LocalRoot, + title: Option<String>, + body: Option<String>, + state: String, + labels: Vec<String>, + assignees: Vec<String>, + key: Option<PathBuf>, +) -> Result<String> { + let (title, body) = match title { + Some(title) => (title, body.unwrap_or_default()), + None => compose_in_editor()? + .ok_or_else(|| Error::InvalidArgument("empty issue message, aborting".into()))?, + }; + let signer = signer(root, key)?; + let identity = Identity { + actor: actor(&signer), + sign: &|payload| signer.sign(payload), + }; + let new = NewIssue { + title, + body, + state, + assignees: assignees.into_iter().map(MemberId::new).collect(), + labels, + }; + let (id, outcome) = issue::new( + &root.refs, + &root.objects, + &root.events, + new, + &identity, + root.mode(), + )?; + outcome_to_result(outcome, None)?; + Ok(id) +} + +/// `git ents issue edit`: mutate `id`'s state, assignees, and/or labels. +/// Assignees/labels replace the previous set entirely when at least one +/// value is given; an empty list leaves that field unchanged. +/// +/// # Errors +/// +/// See [`crate::mutate::outcome_to_result`]. +pub fn edit( + root: &LocalRoot, + id: &str, + state: Option<String>, + labels: Vec<String>, + assignees: Vec<String>, + key: Option<PathBuf>, +) -> Result<()> { + let signer = signer(root, key)?; + let identity = Identity { + actor: actor(&signer), + sign: &|payload| signer.sign(payload), + }; + let edit = EditIssue { + state, + labels: (!labels.is_empty()).then_some(labels), + assignees: (!assignees.is_empty()) + .then(|| assignees.into_iter().map(MemberId::new).collect()), + }; + let outcome = issue::edit( + &root.refs, + &root.objects, + &root.events, + id, + edit, + &identity, + root.mode(), + )?; + outcome_to_result(outcome, None)?; + Ok(()) +} + +/// Compose a title (first line) and body (remaining lines) by opening +/// `$GIT_EDITOR` (or `$EDITOR`, or `vi`) on a scratch file seeded with a +/// `#`-prefixed instructions footer; lines starting with `#` are stripped +/// on read-back. Returns `None` (mirroring `git commit`'s own +/// empty-message abort) when the title line is empty after stripping. +/// +/// # Errors +/// +/// [`Error::Io`] if the scratch file cannot be created, written, or read, +/// or the editor process cannot be spawned or exits with a failure status. +fn compose_in_editor() -> Result<Option<(String, String)>> { + let editor = std::env::var("GIT_EDITOR") + .or_else(|_| std::env::var("EDITOR")) + .unwrap_or_else(|_| "vi".to_owned()); + + let mut file = tempfile::NamedTempFile::new().map_err(|source| Error::Io { + path: std::env::temp_dir(), + source, + })?; + let path = file.path().to_owned(); + writeln!( + file, + "\n# First line is the title, the rest is the body.\n\ + # Lines starting with '#' are stripped; an empty title aborts." + ) + .map_err(|source| Error::Io { + path: path.clone(), + source, + })?; + file.flush().map_err(|source| Error::Io { + path: path.clone(), + source, + })?; + + let status = Command::new(&editor) + .arg(&path) + .status() + .map_err(|source| Error::Io { + path: path.clone(), + source, + })?; + if !status.success() { + return Err(Error::Io { + path: path.clone(), + source: std::io::Error::other(format!("{editor} exited with {status}")), + }); + } + + let contents = std::fs::read_to_string(&path).map_err(|source| Error::Io { + path: path.clone(), + source, + })?; + let mut lines = contents.lines().filter(|line| !line.starts_with('#')); + let title = lines.next().unwrap_or("").trim(); + if title.is_empty() { + return Ok(None); + } + let body = lines.collect::<Vec<_>>().join("\n"); + Ok(Some((title.to_owned(), body.trim_end().to_owned()))) +}
crates/cli/git-ents/src/commands/review.rs @@ -1,0 +1,69 @@ +//! `git ents review`: a thin wrapper around `ents_forge::review`'s +//! business logic — this module only resolves the signer/actor identity +//! against [`LocalRoot`] and translates a reached `Outcome` into a +//! CLI-facing [`Result`] (`crate::mutate::outcome_to_result`), exactly as +//! every other mutation command does. Every operation is the library call +//! itself (`lens.parity`); nothing here re-implements one. + +use ents_forge::comment::Comment; +use ents_forge::review; +use ents_forge::review::{NewReview, Review}; +use ents_receive::Identity; + +use super::{actor, signer}; +use crate::error::Result; +use crate::mutate::outcome_to_result; +use crate::root::LocalRoot; + +/// `git ents review new`: review a commit, writing both its entity ref and +/// its retention pin. +/// +/// # Errors +/// +/// [`crate::error::Error::Forge`] if `new.target` does not resolve to a +/// commit, or serialization or `receive` itself fails for either ref; see +/// [`crate::mutate::outcome_to_result`] for how a reached refusal renders. +pub fn new(root: &LocalRoot, new: NewReview, key: Option<std::path::PathBuf>) -> Result<String> { + let signer = signer(root, key)?; + let identity = Identity { + actor: actor(&signer), + sign: &|payload| signer.sign(payload), + }; + let (id, entity_outcome, pin_outcome) = review::new( + &root.refs, + &root.objects, + &root.events, + &root.path, + new, + &identity, + root.mode(), + )?; + outcome_to_result(entity_outcome, None)?; + outcome_to_result(pin_outcome, None)?; + Ok(id) +} + +/// `git ents review list [--target rev]`: every review recorded in this +/// repository, optionally filtered to those reviewing `target`. +/// +/// # Errors +/// +/// Propagates a ref-store, object read, or revision-resolution failure. +pub fn list(root: &LocalRoot, target: Option<String>) -> Result<Vec<(String, Review)>> { + Ok(review::list( + &root.refs, + &root.objects, + &root.path, + target.as_deref(), + )?) +} + +/// `git ents review show`: `id`'s review, plus its discussion thread. +/// +/// # Errors +/// +/// [`crate::error::Error::Forge`] (wrapping [`ents_forge::Error::NotFound`]) +/// if `id` has no review ref. +pub fn show(root: &LocalRoot, id: &str) -> Result<(Review, Vec<(String, Comment)>)> { + Ok(review::show(&root.refs, &root.objects, id)?) +}
crates/cli/git-ents/tests/issue.rs @@ -1,0 +1,160 @@ +//! Integration coverage for `git ents issue` against a real local +//! composition root (`roots.local`): `new` via an explicit `--title` +//! (a direct library call), `new` via a fake `$EDITOR` script run through +//! the actual `git-ents` binary (the interactive-composition path — a +//! subprocess so `$EDITOR` is scoped to the child rather than mutating +//! this test process's own environment), and `edit` round-tripping +//! state/assignees/labels. + +#![allow(clippy::expect_used, reason = "integration test")] + +mod common; + +use std::process::Command; + +use ents_model::MemberId; +use git_ents::commands::issue; +use git_ents::root::LocalRoot; + +/// Write an executable script at `path` that overwrites its one argument +/// (the scratch file `git ents issue new` opens) with `contents` — a +/// stand-in for a real `$EDITOR`, exercising the same +/// spawn-and-read-back path a real editor would. +fn write_fake_editor(path: &std::path::Path, contents: &str) { + let script = format!("#!/bin/sh\ncat > \"$1\" <<'EOF'\n{contents}\nEOF\n"); + std::fs::write(path, script).expect("write fake editor"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + let mut perms = std::fs::metadata(path).expect("metadata").permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(path, perms).expect("chmod"); + } +} + +/// `git ents issue new --title ...`: no editor needed, the title and body +/// round-trip exactly. +// @relation(model.issue, roots.local, scope=function, role=Verifies) +#[test] +fn issue_new_with_an_explicit_title_skips_the_editor() { + let fixture = common::Fixture::new(1); + let root = LocalRoot::open(fixture.path()).expect("opens"); + + let id = issue::new( + &root, + Some("gate rejects a valid signature".to_owned()), + Some("steps to reproduce...".to_owned()), + "open".to_owned(), + vec!["bug".to_owned()], + vec!["jdc".to_owned()], + Some(fixture.key_path.clone()), + ) + .expect("creates"); + + let found = issue::show(&root, &id).expect("shows"); + assert_eq!(found.title, "gate rejects a valid signature"); + assert_eq!(found.body, "steps to reproduce..."); + assert_eq!(found.state, "open"); + assert_eq!(found.labels, vec!["bug".to_owned()]); + assert_eq!(found.assignees, vec![MemberId::new("jdc")]); +} + +/// `git ents issue new` with no `--title`, run as the real binary with a +/// fake `$EDITOR`: composes the title and body from the scratch file — +/// first line title, remaining lines body, `#` lines stripped. +// @relation(model.issue, roots.local, scope=function, role=Verifies) +#[test] +fn issue_new_composes_title_and_body_from_a_fake_editor() { + let fixture = common::Fixture::new(2); + let editor_path = fixture.path().join("fake-editor.sh"); + write_fake_editor( + &editor_path, + "issue title from the editor\nfirst body line\nsecond body line\n# a stray comment line", + ); + + let output = Command::new(common::bin_path()) + .current_dir(fixture.path()) + .args(["issue", "new", "--state", "open", "--key"]) + .arg(&fixture.key_path) + .env("GIT_EDITOR", &editor_path) + .env("EDITOR", &editor_path) + .output() + .expect("runs"); + assert!(output.status.success(), "{output:?}"); + let stdout = String::from_utf8(output.stdout).expect("utf8"); + let id = stdout + .trim() + .strip_prefix("opened ") + .expect("prints \"opened <id>\"") + .to_owned(); + + let root = LocalRoot::open(fixture.path()).expect("opens"); + let found = issue::show(&root, &id).expect("shows"); + assert_eq!(found.title, "issue title from the editor"); + assert_eq!(found.body, "first body line\nsecond body line"); +} + +/// An empty editor message (blank title after stripping `#` lines) aborts +/// the command with a failing exit status, mirroring `git commit`'s own +/// empty-message abort. +// @relation(model.issue, roots.local, scope=function, role=Verifies) +#[test] +fn issue_new_aborts_on_an_empty_editor_message() { + let fixture = common::Fixture::new(3); + let editor_path = fixture.path().join("fake-editor.sh"); + write_fake_editor(&editor_path, "# only a comment, no title"); + + let output = Command::new(common::bin_path()) + .current_dir(fixture.path()) + .args(["issue", "new", "--state", "open", "--key"]) + .arg(&fixture.key_path) + .env("GIT_EDITOR", &editor_path) + .env("EDITOR", &editor_path) + .output() + .expect("runs"); + assert!( + !output.status.success(), + "an empty title must abort issue creation: {output:?}" + ); +} + +/// `git ents issue edit`: state, assignees, and labels round-trip through +/// an edit on top of the issue's existing tip. +// @relation(model.issue, roots.local, scope=function, role=Verifies) +#[test] +fn issue_edit_round_trips_state_assignees_and_labels() { + let fixture = common::Fixture::new(4); + let root = LocalRoot::open(fixture.path()).expect("opens"); + + let id = issue::new( + &root, + Some("title".to_owned()), + Some("body".to_owned()), + "open".to_owned(), + vec![], + vec![], + Some(fixture.key_path.clone()), + ) + .expect("creates"); + + issue::edit( + &root, + &id, + Some("triaged".to_owned()), + vec!["bug".to_owned(), "gate".to_owned()], + vec!["jdc".to_owned(), "ci-worker".to_owned()], + Some(fixture.key_path.clone()), + ) + .expect("edits"); + + let found = issue::show(&root, &id).expect("shows"); + assert_eq!(found.state, "triaged"); + assert_eq!(found.labels, vec!["bug".to_owned(), "gate".to_owned()]); + assert_eq!( + found.assignees, + vec![MemberId::new("jdc"), MemberId::new("ci-worker")] + ); + // Title and body are untouched by an edit that names neither. + assert_eq!(found.title, "title"); + assert_eq!(found.body, "body"); +}
crates/cli/git-ents/tests/review.rs @@ -1,0 +1,178 @@ +//! Integration coverage for `git ents review` against a real local +//! composition root (`roots.local`): reviewing a commit writes both refs +//! `model.review` requires — the entity ref and its retention pin +//! (`model.review-pin`), the pin's parents including the reviewed commit +//! and its tree the empty tree — and a review's discussion thread +//! surfaces comments naming it as their context (`model.comment-context`). + +#![allow( + clippy::expect_used, + clippy::indexing_slicing, + reason = "integration test" +)] + +mod common; + +use std::path::Path; +use std::process::Command; + +use ents_forge::comment::NewComment; +use ents_forge::review::NewReview; +use git_ents::commands::{comment, review}; +use git_ents::root::LocalRoot; +use gix_object::{CommitRef, Find, Write as _}; +use gix_ref_store::RefStoreRead as _; + +/// Seed `dir`'s working tree with `path` and commit it under a fixed test +/// identity, returning the new commit's id. +fn commit_file(dir: &Path, path: &str, contents: &str) -> gix_hash::ObjectId { + std::fs::write(dir.join(path), contents).expect("write"); + let status = Command::new("git") + .arg("-C") + .arg(dir) + .args(["add", "-A"]) + .status() + .expect("git add"); + assert!(status.success()); + let status = Command::new("git") + .arg("-C") + .arg(dir) + .args([ + "-c", + "user.name=test", + "-c", + "user.email=test@example.com", + "commit", + "-q", + "-m", + "seed", + ]) + .status() + .expect("git commit"); + assert!(status.success()); + let output = Command::new("git") + .arg("-C") + .arg(dir) + .args(["rev-parse", "HEAD"]) + .output() + .expect("rev-parse"); + let hex = String::from_utf8(output.stdout).expect("utf8"); + hex.trim().parse().expect("valid oid") +} + +/// `model.review`, `model.review-pin`: `git ents review new` writes both +/// the review's own entity ref and its retention pin, and the pin's tip +/// commit is a merge-shaped, empty-tree commit whose parents include the +/// reviewed commit — the reachability edge `model.review-pin` requires. +// @relation(model.review, model.review-pin, roots.local, scope=function, role=Verifies) +#[test] +fn review_new_writes_both_refs_with_the_pin_parented_on_the_reviewed_commit() { + let fixture = common::Fixture::new(1); + let reviewed = commit_file(fixture.path(), "file.txt", "line one\n"); + let root = LocalRoot::open(fixture.path()).expect("opens"); + + let new = NewReview { + target: "HEAD".to_owned(), + verdict: "approve".to_owned(), + body: "looks good".to_owned(), + }; + let id = review::new(&root, new, Some(fixture.key_path.clone())).expect("reviews"); + + // The entity ref exists and reads back verdict, body, and the + // reviewed commit as a plain data field — no pin read required. + let (found, _thread) = review::show(&root, &id).expect("shows"); + assert_eq!(found.verdict, "approve"); + assert_eq!(found.body, "looks good"); + assert_eq!(found.commit(), reviewed); + + // The pin ref exists; its tip's parents include the reviewed commit, + // and its tree is the empty tree — the sole exception to + // `meta-ref.namespace`'s tree-is-the-entity shape. + let pin_ref = ents_model::namespace::review_pin_ref(&id).expect("valid"); + let pin_tip = root + .refs + .get(pin_ref.as_ref()) + .expect("reads") + .expect("pin ref exists"); + let mut buf = Vec::new(); + let data = root + .objects + .try_find(&pin_tip, &mut buf) + .expect("reads") + .expect("pin commit exists"); + let commit = CommitRef::from_bytes(data.data, pin_tip.kind()).expect("parses"); + assert!( + commit.parents().any(|parent| parent == reviewed), + "pin's parents must include the reviewed commit" + ); + let empty_tree = root + .objects + .write(&gix_object::Tree { entries: vec![] }) + .expect("writes empty tree"); + assert_eq!(commit.tree(), empty_tree); +} + +/// `model.comment-context`, `model.review`: a comment naming +/// `reviews/<id>` as its context surfaces in `git ents review show`'s +/// thread — the review itself stores no list of its comments. +// @relation(model.review, model.comment-context, roots.local, scope=function, role=Verifies) +#[test] +fn review_show_surfaces_a_context_comment() { + let fixture = common::Fixture::new(1); + commit_file(fixture.path(), "file.txt", "line one\n"); + let root = LocalRoot::open(fixture.path()).expect("opens"); + + let new = NewReview { + target: "HEAD".to_owned(), + verdict: "request-changes".to_owned(), + body: "one nit".to_owned(), + }; + let id = review::new(&root, new, Some(fixture.key_path.clone())).expect("reviews"); + + let draft = NewComment { + body: "please rename this".to_owned(), + path: None, + lines: None, + rev: "HEAD".to_owned(), + worktree: false, + context: Some(format!("reviews/{id}")), + parent: None, + }; + comment::add(&root, draft, Some(fixture.key_path.clone())).expect("comments"); + + let (_review, thread) = review::show(&root, &id).expect("shows"); + assert_eq!(thread.len(), 1); + assert_eq!(thread[0].1.body, "please rename this"); +} + +/// `git ents review list [--target rev]`: filtering by target keeps only +/// reviews of that commit. +// @relation(model.review, roots.local, scope=function, role=Verifies) +#[test] +fn review_list_filters_by_target() { + let fixture = common::Fixture::new(1); + let first = commit_file(fixture.path(), "file.txt", "line one\n"); + let second = commit_file(fixture.path(), "file.txt", "line one\nline two\n"); + let root = LocalRoot::open(fixture.path()).expect("opens"); + + let review_of_first = NewReview { + target: first.to_string(), + verdict: "approve".to_owned(), + body: String::new(), + }; + let first_id = + review::new(&root, review_of_first, Some(fixture.key_path.clone())).expect("reviews"); + let review_of_second = NewReview { + target: second.to_string(), + verdict: "approve".to_owned(), + body: String::new(), + }; + review::new(&root, review_of_second, Some(fixture.key_path.clone())).expect("reviews"); + + let all = review::list(&root, None).expect("lists"); + assert_eq!(all.len(), 2); + + let filtered = review::list(&root, Some(first.to_string())).expect("lists"); + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].0, first_id); +}