roots: add review and extend issue porcelain in git-ents
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.
No reviews of this commit yet — record a verdict below.
Start a review
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/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);
+}