git-ents.gitmain
⌘K
foforge
commit 47fdcb6
feat: add a comments sub-collection keyed off an issue's stable id

Each comment gets its own ref, refs/meta/comments/<issue_id>/<comment_id>, nested under the issue’s stable genesis key so a comment filed before promotion still resolves after (promotion never renames that key). comment_id is content-addressed exactly like an issue’s own genesis key, so concurrent additions never collide and no counter is needed. Built entirely on the Phase 3a Store helpers (load_item/store_item/ list_items), so the CAS-and-merge behavior is inherited for free.

feat: add comments::new_id/load/store/list and their _with variants docs: add the comments.ref requirement 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

docs/specification.adoc @@ -365,6 +365,19 @@ identifier, not the friendly number. -- +=== Comments + +[role="requirement", id="comments.ref"] +.Comment Documents +-- +Each comment on an issue MUST be stored at +`refs/meta/comments/<issue_id>/<comment_id>` as a `Comment` document with +fields `body`, `author`, and `issue_id`, where `issue_id` is the issue's +stable content-hash identifier and `comment_id` is the comment's own content +hash. One ref per comment keeps comments independently loadable and separately +historied, and content-hash ids mean concurrent additions never collide. +-- + === Web UI [role="requirement", id="web.server-rendered"]
crates/git-ents/src/lib.rs @@ -2,6 +2,7 @@ pub mod account; pub mod checks; +pub mod comments; pub mod config; pub mod issues; pub mod members;
crates/git-ents/src/comments.rs @@ -1,0 +1,202 @@ +//! Comments on issues, sourced from the +//! `refs/meta/comments/<issue_id>/<comment_id>` refs. +//! +//! Each comment is its own document nested under the issue's stable genesis +//! key — never its friendly number, so a comment filed before promotion still +//! resolves after. The comment's own id is the ref's last segment: the hash of +//! its own content, content-addressed exactly like an issue's genesis key +//! ([`git_ents::issues`](crate::issues)), so concurrent additions never +//! collide and no counter is needed. + +use std::path::Path; + +use facet::Facet; + +/// The namespace under which comments are recorded: one ref, +/// `refs/meta/comments/<issue_id>/<comment_id>`, per comment. +pub const COMMENTS_NS: &str = "refs/meta/comments"; + +/// One comment on an issue, stored at +/// `refs/meta/comments/<issue_id>/<comment_id>`. +#[derive(Debug, Clone, PartialEq, Eq, Facet)] +pub struct Comment { + /// The comment's body text. + pub body: String, + /// The identity that wrote the comment. + pub author: String, + /// The commented-on issue's stable genesis key — never its friendly + /// number, so a comment filed before promotion still resolves after. + pub issue_id: String, +} + +/// The ref namespace holding every comment on `issue_id`. +fn issue_comments_ns(issue_id: &str) -> String { + format!("{COMMENTS_NS}/{issue_id}") +} + +/// Derive a comment's stable id: the hash of its own content, since a comment +/// derives from nothing upstream and so always originates itself — the same +/// content-addressing [`issues::new_id`](crate::issues::new_id) uses when an +/// issue has no origin either. +pub fn new_id(content: &Comment) -> Result<String, git_store::Error> { + git_store::content_hash(content) +} + +/// Load the comment `comment_id` on `issue_id` from an already-open `store`. +pub fn load_with( + store: &git_store::Store, + issue_id: &str, + comment_id: &str, +) -> Result<Option<Comment>, git_store::Error> { + store.load_item(&issue_comments_ns(issue_id), comment_id) +} + +/// Load the comment `comment_id` on `issue_id` in `repo`. See [`load_with`]. +pub fn load( + repo: &Path, + issue_id: &str, + comment_id: &str, +) -> Result<Option<Comment>, git_store::Error> { + load_with(&git_store::Store::open(repo)?, issue_id, comment_id) +} + +/// Write `comment` at `refs/meta/comments/<issue_id>/<comment_id>` through an +/// already-open `store`, where `comment_id` is [`new_id`] of `comment`. +pub fn store_with( + store: &git_store::Store, + issue_id: &str, + comment_id: &str, + comment: &Comment, +) -> Result<(), git_store::Error> { + store.store_item( + &issue_comments_ns(issue_id), + comment_id, + comment, + "Add comment", + ) +} + +/// Write `comment`. See [`store_with`]. +pub fn store( + repo: &Path, + issue_id: &str, + comment_id: &str, + comment: &Comment, +) -> Result<(), git_store::Error> { + store_with( + &git_store::Store::open(repo)?, + issue_id, + comment_id, + comment, + ) +} + +/// List every comment on `issue_id` from an already-open `store`, as +/// `(comment_id, comment)` pairs, newest first. +pub fn list_with( + store: &git_store::Store, + issue_id: &str, +) -> Result<Vec<(String, Comment)>, git_store::Error> { + store.list_items(&issue_comments_ns(issue_id)) +} + +/// List every comment on `issue_id` in `repo`. See [`list_with`]. +pub fn list(repo: &Path, issue_id: &str) -> Result<Vec<(String, Comment)>, git_store::Error> { + list_with(&git_store::Store::open(repo)?, issue_id) +} + +#[cfg(test)] +mod tests { + #![allow( + clippy::unwrap_used, + clippy::let_underscore_must_use, + reason = "unit test" + )] + + use super::*; + use crate::testutil::unique_repo as new_repo; + + fn unique_repo() -> std::path::PathBuf { + new_repo("comments") + } + + fn comment(issue_id: &str, body: &str) -> Comment { + Comment { + body: body.to_owned(), + author: "alice".to_owned(), + issue_id: issue_id.to_owned(), + } + } + + #[test] + fn store_then_load_round_trips_a_comment() { + let repo = unique_repo(); + let written = comment("issue-1", "A comment"); + let id = new_id(&written).unwrap(); + store(&repo, "issue-1", &id, &written).unwrap(); + assert_eq!(load(&repo, "issue-1", &id).unwrap(), Some(written)); + let _ = std::fs::remove_dir_all(&repo); + } + + #[test] + fn none_when_the_comment_is_absent() { + let repo = unique_repo(); + assert_eq!(load(&repo, "issue-1", "missing").unwrap(), None); + let _ = std::fs::remove_dir_all(&repo); + } + + #[test] + fn lists_only_the_comments_on_the_named_issue() { + let repo = unique_repo(); + let a = comment("issue-1", "First"); + let b = comment("issue-1", "Second"); + let other = comment("issue-2", "Unrelated"); + store(&repo, "issue-1", &new_id(&a).unwrap(), &a).unwrap(); + store(&repo, "issue-1", &new_id(&b).unwrap(), &b).unwrap(); + store(&repo, "issue-2", &new_id(&other).unwrap(), &other).unwrap(); + + let mut bodies: Vec<String> = list(&repo, "issue-1") + .unwrap() + .into_iter() + .map(|(_id, comment)| comment.body) + .collect(); + bodies.sort(); + assert_eq!(bodies, vec!["First".to_owned(), "Second".to_owned()]); + let _ = std::fs::remove_dir_all(&repo); + } + + #[test] + fn new_id_is_content_addressed() { + let a = comment("issue-1", "Same text"); + let b = comment("issue-1", "Same text"); + let different = comment("issue-1", "Different text"); + assert_eq!(new_id(&a).unwrap(), new_id(&b).unwrap()); + assert_ne!(new_id(&a).unwrap(), new_id(&different).unwrap()); + } + + #[test] + fn a_comment_filed_before_promotion_still_resolves_after() { + let repo = unique_repo(); + let issue = crate::issues::Issue { + title: "A bug".to_owned(), + body: "A body".to_owned(), + state: "open".to_owned(), + labels: vec![], + author: "alice".to_owned(), + id: None, + }; + let issue_id = crate::issues::new_id(None, &issue).unwrap(); + crate::issues::store(&repo, &issue_id, &issue).unwrap(); + + let written = comment(&issue_id, "Filed before promotion"); + let comment_id = new_id(&written).unwrap(); + store(&repo, &issue_id, &comment_id, &written).unwrap(); + + crate::issues::promote(&repo, &issue_id).unwrap(); + + // The comment is keyed off the stable genesis id, which promotion + // never renames, so it still resolves. + assert_eq!(load(&repo, &issue_id, &comment_id).unwrap(), Some(written)); + let _ = std::fs::remove_dir_all(&repo); + } +}