crates/forge/ents-forge/src/issue/command.rs
command.rshistorycomment on this file
| 1 | //! The `issue` command's business logic: create an issue |
| 2 | //! (`model.issue`), edit its state/assignees/labels, list, and read one |
| 3 | //! back. |
| 4 | //! |
| 5 | //! Generalized over the same trait-object/generic seam |
| 6 | //! `crate::comment::command` uses (`&dyn RefStore`/`RefStoreRead`, |
| 7 | //! `impl Find`/`Find + Write`, `&dyn ents_receive::EventSink`), so a |
| 8 | //! composition root wires the concrete types and calls these functions, |
| 9 | //! never the other way around (`lens.parity`). Obtaining a title/body from |
| 10 | //! an interactive editor is a frontend concern — the CLI's own |
| 11 | //! `commands::issue` resolves that before calling [`new`] here — not an |
| 12 | //! operation this crate's library layer offers, mirroring how |
| 13 | //! `crate::comment::command` never touches a terminal either. |
| 14 | |
| 15 | use ents_model::MemberId; |
| 16 | use ents_receive::{Identity, Mode, Outcome, propose_entity, propose_genesis}; |
| 17 | use gix_hash::ObjectId; |
| 18 | use gix_object::{CommitRef, Find, Kind}; |
| 19 | |
| 20 | use super::Issue; |
| 21 | use crate::error::{Error, Result}; |
| 22 | |
| 23 | /// The tree of the commit at `oid` — duplicated from |
| 24 | /// `crate::comment::command`'s own copy; see that copy's doc for why this |
| 25 | /// codebase accepts one small copy per module rather than a shared helper. |
| 26 | fn commit_tree(objects: &impl Find, oid: ObjectId) -> Result<ObjectId> { |
| 27 | let mut buf = Vec::new(); |
| 28 | let data = objects |
| 29 | .try_find(&oid, &mut buf) |
| 30 | .map_err(|source| Error::InvalidArgument(source.to_string()))? |
| 31 | .ok_or_else(|| Error::NotFound { |
| 32 | what: oid.to_string(), |
| 33 | })?; |
| 34 | if data.kind != Kind::Commit { |
| 35 | return Err(Error::NotFound { |
| 36 | what: oid.to_string(), |
| 37 | }); |
| 38 | } |
| 39 | let commit = CommitRef::from_bytes(data.data, oid.kind()) |
| 40 | .map_err(|source| Error::InvalidArgument(source.to_string()))?; |
| 41 | Ok(commit.tree()) |
| 42 | } |
| 43 | |
| 44 | /// Read the [`Issue`] at `id`'s ref tip, or [`Error::NotFound`] when no |
| 45 | /// such ref exists. |
| 46 | fn issue_at( |
| 47 | refs: &dyn gix_ref_store::RefStoreRead, |
| 48 | objects: &impl Find, |
| 49 | id: &str, |
| 50 | ) -> Result<Issue> { |
| 51 | let ref_name = ents_model::namespace::issue_ref(id)?; |
| 52 | let Some(tip) = refs.get(ref_name.as_ref())? else { |
| 53 | return Err(Error::NotFound { |
| 54 | what: format!("issue {id}"), |
| 55 | }); |
| 56 | }; |
| 57 | let tree = commit_tree(objects, tip)?; |
| 58 | Ok(facet_git_tree::deserialize(&tree, objects)?) |
| 59 | } |
| 60 | |
| 61 | /// What `git ents issue new` writes. |
| 62 | #[derive(Debug, Clone)] |
| 63 | pub struct NewIssue { |
| 64 | /// The issue's title. |
| 65 | pub title: String, |
| 66 | /// The issue's body. |
| 67 | pub body: String, |
| 68 | /// The issue's initial state; a new issue's state has no platform |
| 69 | /// default (`model.issue`: custom states are schema, not a platform |
| 70 | /// feature) — the CLI's own default is `"open"`. |
| 71 | pub state: String, |
| 72 | /// Members assigned to the issue at creation. |
| 73 | pub assignees: Vec<MemberId>, |
| 74 | /// Labels attached at creation. |
| 75 | pub labels: Vec<String>, |
| 76 | } |
| 77 | |
| 78 | /// `git ents issue new`: create an issue at `refs/meta/issues/<id>`, where |
| 79 | /// `<id>` is the oid of the issue's own genesis commit — sign-then-name, |
| 80 | /// never a locally minted id (`model.issue`, `meta-ref.identity-binding`). |
| 81 | /// |
| 82 | /// # Errors |
| 83 | /// |
| 84 | /// Propagates serialization or `receive` failures. |
| 85 | // @relation(model.issue, meta-ref.identity-binding, lens.parity, scope=function) |
| 86 | pub fn new( |
| 87 | refs: &dyn gix_ref_store::RefStore, |
| 88 | objects: &(impl Find + gix_object::Write), |
| 89 | events: &dyn ents_receive::EventSink, |
| 90 | new: NewIssue, |
| 91 | identity: &Identity<'_>, |
| 92 | mode: Mode, |
| 93 | ) -> Result<(String, Outcome)> { |
| 94 | let issue = Issue { |
| 95 | title: new.title, |
| 96 | body: new.body, |
| 97 | state: new.state, |
| 98 | assignees: new.assignees, |
| 99 | labels: new.labels, |
| 100 | }; |
| 101 | let subject = format!("Open issue: {}", issue.title); |
| 102 | let (ref_name, outcome) = propose_genesis( |
| 103 | refs, |
| 104 | objects, |
| 105 | events, |
| 106 | &issue, |
| 107 | |oid| ents_model::namespace::issue_ref(&oid.to_string()), |
| 108 | identity, |
| 109 | &subject, |
| 110 | mode, |
| 111 | )?; |
| 112 | Ok((crate::genesis_id(&ref_name), outcome)) |
| 113 | } |
| 114 | |
| 115 | /// What `git ents issue edit` changes; a field left `None` is left |
| 116 | /// untouched. |
| 117 | #[derive(Debug, Clone, Default)] |
| 118 | pub struct EditIssue { |
| 119 | /// Replace the issue's state, or leave it unchanged. |
| 120 | pub state: Option<String>, |
| 121 | /// Replace the issue's assignees, or leave them unchanged. |
| 122 | pub assignees: Option<Vec<MemberId>>, |
| 123 | /// Replace the issue's labels, or leave them unchanged. |
| 124 | pub labels: Option<Vec<String>>, |
| 125 | } |
| 126 | |
| 127 | /// `git ents issue edit`: mutate `id`'s state, assignees, and/or labels as |
| 128 | /// an ordinary mutation commit on the issue's own ref, on top of its |
| 129 | /// current tip. |
| 130 | /// |
| 131 | /// # Errors |
| 132 | /// |
| 133 | /// [`Error::NotFound`] if `id` has no issue ref; otherwise propagates |
| 134 | /// serialization or `receive` failures. |
| 135 | // @relation(model.issue, lens.parity, scope=function) |
| 136 | pub fn edit( |
| 137 | refs: &dyn gix_ref_store::RefStore, |
| 138 | objects: &(impl Find + gix_object::Write), |
| 139 | events: &dyn ents_receive::EventSink, |
| 140 | id: &str, |
| 141 | edit: EditIssue, |
| 142 | identity: &Identity<'_>, |
| 143 | mode: Mode, |
| 144 | ) -> Result<Outcome> { |
| 145 | let mut issue = issue_at(refs, objects, id)?; |
| 146 | if let Some(state) = edit.state { |
| 147 | issue.state = state; |
| 148 | } |
| 149 | if let Some(assignees) = edit.assignees { |
| 150 | issue.assignees = assignees; |
| 151 | } |
| 152 | if let Some(labels) = edit.labels { |
| 153 | issue.labels = labels; |
| 154 | } |
| 155 | let ref_name = ents_model::namespace::issue_ref(id)?; |
| 156 | Ok(propose_entity( |
| 157 | refs, |
| 158 | objects, |
| 159 | events, |
| 160 | ref_name, |
| 161 | &issue, |
| 162 | identity, |
| 163 | &format!("Edit issue {id}"), |
| 164 | mode, |
| 165 | )?) |
| 166 | } |
| 167 | |
| 168 | /// `git ents issue list`: every issue recorded in this repository. |
| 169 | /// |
| 170 | /// A ref whose tip this build cannot read back as an [`Issue`] is |
| 171 | /// silently absent here — a caller that must surface those refs instead |
| 172 | /// of dropping them (`ents-web`'s issues page) uses [`list_all`], which |
| 173 | /// this is the readable-rows-only view of. |
| 174 | /// |
| 175 | /// # Errors |
| 176 | /// |
| 177 | /// Propagates a ref-store or object read failure. |
| 178 | /// |
| 179 | /// # Examples |
| 180 | /// |
| 181 | /// ``` |
| 182 | /// use ents_forge::issue::list; |
| 183 | /// use ents_testutil::{MemRefStore, ObjectStore}; |
| 184 | /// |
| 185 | /// let refs = MemRefStore::default(); |
| 186 | /// let objects = ObjectStore::default(); |
| 187 | /// assert!(list(&refs, &objects).expect("reads").is_empty()); |
| 188 | /// ``` |
| 189 | pub fn list( |
| 190 | refs: &dyn gix_ref_store::RefStoreRead, |
| 191 | objects: &impl Find, |
| 192 | ) -> Result<Vec<(String, Issue)>> { |
| 193 | Ok(list_all(refs, objects)?.0) |
| 194 | } |
| 195 | |
| 196 | /// [`list`] plus the refs it could not read: every readable issue, and |
| 197 | /// one [`crate::Unreadable`] per `refs/meta/issues/*` ref whose tip this |
| 198 | /// build's [`Issue`] shape could not read back — the issue counterpart to |
| 199 | /// [`crate::comment::list_all`], with the same never-silently-dropped |
| 200 | /// contract (see [`crate::Unreadable`]'s own doc). |
| 201 | /// |
| 202 | /// # Errors |
| 203 | /// |
| 204 | /// Propagates a ref-store read failure — a per-ref *entity* read failure |
| 205 | /// is a row in the second vec, never an error. |
| 206 | /// |
| 207 | /// # Examples |
| 208 | /// |
| 209 | /// ``` |
| 210 | /// use ents_forge::issue::list_all; |
| 211 | /// use ents_testutil::{MemRefStore, ObjectStore}; |
| 212 | /// |
| 213 | /// let refs = MemRefStore::default(); |
| 214 | /// let objects = ObjectStore::default(); |
| 215 | /// let (rows, unreadable) = list_all(&refs, &objects).expect("reads"); |
| 216 | /// assert!(rows.is_empty()); |
| 217 | /// assert!(unreadable.is_empty()); |
| 218 | /// ``` |
| 219 | pub fn list_all( |
| 220 | refs: &dyn gix_ref_store::RefStoreRead, |
| 221 | objects: &impl Find, |
| 222 | ) -> Result<crate::Listing<Issue>> { |
| 223 | let mut out = Vec::new(); |
| 224 | let mut unreadable = Vec::new(); |
| 225 | for entry in refs.iter_prefix("refs/meta/issues/")? { |
| 226 | let (name, tip) = entry?; |
| 227 | let path = name.as_bstr().to_string(); |
| 228 | let Some(id) = path.strip_prefix("refs/meta/issues/") else { |
| 229 | continue; |
| 230 | }; |
| 231 | match commit_tree(objects, tip) |
| 232 | .and_then(|tree| Ok(facet_git_tree::deserialize::<Issue>(&tree, objects)?)) |
| 233 | { |
| 234 | Ok(issue) => out.push((id.to_owned(), issue)), |
| 235 | Err(error) => unreadable.push(crate::Unreadable { |
| 236 | refname: path.clone(), |
| 237 | error: error.to_string(), |
| 238 | }), |
| 239 | } |
| 240 | } |
| 241 | Ok((out, unreadable)) |
| 242 | } |
| 243 | |
| 244 | /// `git ents issue show`: `id`'s issue. |
| 245 | /// |
| 246 | /// # Errors |
| 247 | /// |
| 248 | /// [`Error::NotFound`] if `id` has no issue ref. |
| 249 | // @relation(model.issue, lens.parity, scope=function) |
| 250 | pub fn show( |
| 251 | refs: &dyn gix_ref_store::RefStoreRead, |
| 252 | objects: &impl Find, |
| 253 | id: &str, |
| 254 | ) -> Result<Issue> { |
| 255 | issue_at(refs, objects, id) |
| 256 | } |