git-ents.gitmain
⌘K
foforge
command.rs256 lines · 8.1 KB · rusthistorycomment 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
15use ents_model::MemberId;
16use ents_receive::{Identity, Mode, Outcome, propose_entity, propose_genesis};
17use gix_hash::ObjectId;
18use gix_object::{CommitRef, Find, Kind};
19
20use super::Issue;
21use 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.
26fn 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.
46fn 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)]
63pub 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)
86pub 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)]
118pub 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)
136pub 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/// ```
189pub 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/// ```
219pub 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)
250pub 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}