Skip to main content

onetaskgraph_core/engine/
comment.rs

1//! Comments on a task: the four verbs that read and write them, and the task detail
2//! `task show` renders them in.
3//!
4//! Every verb here addresses exactly one task of exactly one source, so none of them fans
5//! out, pages for a caller or compensates for anything. What they owe instead is the
6//! refusals: a source declaring no comments is refused before anything is read, a source
7//! whose comments cannot be written is refused before a write is attempted, and a task or a
8//! comment that is not there is named rather than answered with an empty result.
9//!
10//! Nothing here writes anything down. A list walks the source's pages to the end and hands
11//! the caller exactly what it read; a copy never reaches this module at all, which is what
12//! keeps a copy from reading or writing a comment at either end.
13
14use onetaskgraph_plugin_api::{
15    Comment, CommentBody, Cursor, NativeId, NewComment, PageRequest, SourceError, SourceName, Task,
16};
17use schemars::JsonSchema;
18use serde::{Deserialize, Serialize};
19
20use super::fetch::{fits, unrepeated};
21use super::{ConfiguredSource, Engine, EngineError, Qualified};
22use crate::GlobalId;
23use crate::plan::{QueryResponse, SourceFailure};
24use crate::resolve::ResolvedSource;
25
26/// Every comment on one task, oldest first: what `task comment list` answers with.
27///
28/// An object rather than a bare list, so a later member — a total, say — is an addition a
29/// reader already written against this shape can ignore.
30#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
31pub struct CommentList {
32    /// The task's comments in the order they were written. Empty when it has none.
33    pub comments: Vec<Comment>,
34}
35
36/// What `task comment delete` answers with: the id of the comment it removed.
37#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
38pub struct DeletedComment {
39    /// The id the comment was removed under, exactly as `list` reported it.
40    pub deleted: NativeId,
41}
42
43/// One task as `task show` reports it: the response every show verb answers with, and the
44/// task's comments beside it.
45///
46/// The response is flattened rather than nested, so a reader of `task show --json` written
47/// before comments existed reads exactly the members it read before.
48#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
49pub struct TaskDetail {
50    /// The task, its plan and any failure, exactly as [`Engine::task`] answers.
51    #[serde(flatten)]
52    pub response: QueryResponse<Qualified<Task>>,
53    /// The task's comments, oldest first, for a source whose tasks have comments.
54    ///
55    /// **Absent** rather than empty for a source declaring none, for a task that was not
56    /// found, and for a task whose comments could not be read — the last with the failure in
57    /// the response's `errors`, a source refusing the read (a GitHub draft, which has none)
58    /// included, so showing such a task is a partial answer that says why. An empty list says
59    /// the source has comments and this task holds none, which is a different thing to tell a
60    /// reader.
61    #[serde(default, skip_serializing_if = "Option::is_none")]
62    pub comments: Option<Vec<Comment>>,
63}
64
65impl Engine {
66    /// One task by its qualified id, with its comments when its source has them.
67    ///
68    /// The task is read exactly as [`task`](Self::task) reads it, and the comments are read
69    /// only once the task was found — a source declaring no comments is never asked.
70    ///
71    /// # Errors
72    ///
73    /// As [`task`](Self::task). A comment read that fails is not an error: it lands in the
74    /// response's `errors` beside the task that was read.
75    pub async fn task_detail(&self, id: &GlobalId) -> Result<TaskDetail, EngineError> {
76        let mut response = self.task(id).await?;
77        let source = match self.configured(&id.source) {
78            Some(ConfiguredSource::Ready(source))
79                if !response.items.is_empty()
80                    && source.source().capabilities().comments.is_native() =>
81            {
82                source
83            }
84            _ => {
85                return Ok(TaskDetail {
86                    response,
87                    comments: None,
88                });
89            }
90        };
91        let comments = match walk(source, &id.native).await {
92            Ok(comments) => comments,
93            Err(error) => {
94                response.errors.push(SourceFailure {
95                    source: source.name().clone(),
96                    error,
97                });
98                None
99            }
100        };
101        Ok(TaskDetail { response, comments })
102    }
103
104    /// Every comment on one task, oldest first.
105    ///
106    /// # Errors
107    ///
108    /// Returns [`EngineError::NoComments`] for a source whose tasks have none, before
109    /// anything is read; [`EngineError::NoSuchTask`] when the source holds no such task; and
110    /// [`EngineError::SourceFailed`] when the source could not answer.
111    pub async fn comments(&self, task: &GlobalId) -> Result<CommentList, EngineError> {
112        let source = self.commented(&task.source)?;
113        match walk(source, &task.native).await {
114            Ok(Some(comments)) => Ok(CommentList { comments }),
115            Ok(None) => Err(no_such_task(task)),
116            Err(error) => Err(failed(source, error)),
117        }
118    }
119
120    /// Add one comment to a task, answering with the comment as its source now holds it.
121    ///
122    /// # Errors
123    ///
124    /// As [`comments`](Self::comments), plus [`EngineError::CommentsNotWritable`] for a
125    /// source whose comments cannot be written, before anything is written.
126    pub async fn add_comment(
127        &self,
128        task: &GlobalId,
129        comment: &NewComment,
130    ) -> Result<Comment, EngineError> {
131        let source = self.writable_comments(&task.source)?;
132        match source.source().add_comment(&task.native, comment).await {
133            Ok(Some(added)) => Ok(added),
134            Ok(None) => Err(no_such_task(task)),
135            Err(error) => Err(failed(source, error)),
136        }
137    }
138
139    /// Replace one comment's body, answering with the comment as its source now holds it.
140    ///
141    /// # Errors
142    ///
143    /// As [`add_comment`](Self::add_comment), plus [`EngineError::NoSuchComment`] when the
144    /// task has no comment under `comment`.
145    pub async fn edit_comment(
146        &self,
147        task: &GlobalId,
148        comment: &NativeId,
149        body: &CommentBody,
150    ) -> Result<Comment, EngineError> {
151        let source = self.writable_comments(&task.source)?;
152        match source
153            .source()
154            .edit_comment(&task.native, comment, body)
155            .await
156        {
157            Ok(Some(edited)) => Ok(edited),
158            Ok(None) => Err(missing(source, task, comment).await),
159            Err(error) => Err(failed(source, error)),
160        }
161    }
162
163    /// Remove one comment from a task, answering with the id it removed.
164    ///
165    /// # Errors
166    ///
167    /// As [`edit_comment`](Self::edit_comment).
168    pub async fn delete_comment(
169        &self,
170        task: &GlobalId,
171        comment: &NativeId,
172    ) -> Result<DeletedComment, EngineError> {
173        let source = self.writable_comments(&task.source)?;
174        match source.source().delete_comment(&task.native, comment).await {
175            Ok(Some(deleted)) => Ok(DeletedComment { deleted }),
176            Ok(None) => Err(missing(source, task, comment).await),
177            Err(error) => Err(failed(source, error)),
178        }
179    }
180
181    /// The configured source called `name`, in whichever state it is in.
182    fn configured(&self, name: &SourceName) -> Option<&ConfiguredSource> {
183        self.sources.iter().find(|source| source.name() == name)
184    }
185
186    /// The built source called `name`, when its tasks have comments.
187    fn commented(&self, name: &SourceName) -> Result<&ResolvedSource, EngineError> {
188        let name = self.known(name)?;
189        match self.configured(&name) {
190            Some(ConfiguredSource::Ready(source)) => {
191                if source.source().capabilities().comments.is_native() {
192                    Ok(source)
193                } else {
194                    Err(EngineError::NoComments {
195                        name: name.to_string(),
196                        kind: source.kind().to_owned(),
197                    })
198                }
199            }
200            Some(ConfiguredSource::Unavailable(source)) => Err(EngineError::SourceUnavailable {
201                name: name.to_string(),
202                error: source.error().clone(),
203            }),
204            // `known` has just said a source by this name is configured, and `configured`
205            // reads the same list it read.
206            None => Err(EngineError::NoSources),
207        }
208    }
209
210    /// The built source called `name`, when its tasks have comments it can write.
211    fn writable_comments(&self, name: &SourceName) -> Result<&ResolvedSource, EngineError> {
212        let source = self.commented(name)?;
213        if source.source().writes().is_supported() {
214            return Ok(source);
215        }
216        Err(EngineError::CommentsNotWritable {
217            name: source.name().to_string(),
218            kind: source.kind().to_owned(),
219        })
220    }
221}
222
223/// Every comment on `task`, walked to the end of the source's pages, or `None` when the
224/// source holds no such task.
225///
226/// Each page is asked at the source's own ceiling, and each is held to the two refusals every
227/// pagination loop of this engine owes: a page longer than the one asked for, and a cursor
228/// handed back unchanged. What the walk accumulates is the caller's answer and nothing else.
229async fn walk(
230    source: &ResolvedSource,
231    task: &NativeId,
232) -> Result<Option<Vec<Comment>>, SourceError> {
233    let limit = source.source().capabilities().max_page_size.max(1);
234    let mut comments = Vec::new();
235    let mut cursor: Option<Cursor> = None;
236    loop {
237        let request = PageRequest {
238            cursor: cursor.clone(),
239            limit,
240        };
241        // A task that is gone part way through a walk is gone: reporting the comments read
242        // before it went would describe a task nobody can address any more.
243        let Some(page) = source.source().task_comments(task, &request).await? else {
244            return Ok(None);
245        };
246        fits(page.items.len(), limit)?;
247        unrepeated(
248            page.next.as_ref(),
249            cursor.as_ref(),
250            "walking a task's comments",
251        )?;
252        comments.extend(page.items);
253        match page.next {
254            Some(next) => cursor = Some(next),
255            None => return Ok(Some(comments)),
256        }
257    }
258}
259
260/// Which of the two things an edit or a delete named was not there.
261///
262/// Asked only once the source has already said one of them is missing, so a comment verb
263/// that succeeds costs its source exactly one call.
264async fn missing(source: &ResolvedSource, task: &GlobalId, comment: &NativeId) -> EngineError {
265    match source.source().get_task(&task.native).await {
266        Ok(Some(_)) => EngineError::NoSuchComment {
267            task: task.to_string(),
268            comment: comment.to_string(),
269        },
270        Ok(None) => no_such_task(task),
271        Err(error) => failed(source, error),
272    }
273}
274
275fn no_such_task(task: &GlobalId) -> EngineError {
276    EngineError::NoSuchTask {
277        id: task.to_string(),
278    }
279}
280
281fn failed(source: &ResolvedSource, error: SourceError) -> EngineError {
282    EngineError::SourceFailed {
283        name: source.name().to_string(),
284        error,
285    }
286}