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 chrono::{DateTime, Utc};
15use onetaskgraph_plugin_api::{
16    Comment, CommentBody, Cursor, NativeId, NewComment, Page, PageRequest, SourceError, SourceName,
17    Task, TaskQuery,
18};
19use schemars::JsonSchema;
20use serde::{Deserialize, Serialize};
21
22use super::fetch::{fits, unrepeated};
23use super::{ConfiguredSource, Engine, EngineError, Qualified};
24use crate::GlobalId;
25use crate::plan::{QueryResponse, SourceFailure};
26use crate::resolve::ResolvedSource;
27
28/// Every comment on one task, oldest first: what `task comment list` answers with.
29///
30/// An object rather than a bare list, so a later member — a total, say — is an addition a
31/// reader already written against this shape can ignore.
32#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
33pub struct CommentList {
34    /// The task's comments in the order they were written. Empty when it has none.
35    pub comments: Vec<Comment>,
36}
37
38/// What `task comment delete` answers with: the id of the comment it removed.
39#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
40pub struct DeletedComment {
41    /// The id the comment was removed under, exactly as `list` reported it.
42    pub deleted: NativeId,
43}
44
45/// One task as `task show` reports it: the response every show verb answers with, and the
46/// task's comments beside it.
47///
48/// The response is flattened rather than nested, so a reader of `task show --json` written
49/// before comments existed reads exactly the members it read before.
50#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
51pub struct TaskDetail {
52    /// The task, its plan and any failure, exactly as [`Engine::task`] answers.
53    #[serde(flatten)]
54    pub response: QueryResponse<Qualified<Task>>,
55    /// The task's comments, oldest first, for a source whose tasks have comments.
56    ///
57    /// **Absent** rather than empty for a source declaring none, for a task that was not
58    /// found, and for a task whose comments could not be read — the last with the failure in
59    /// the response's `errors`, a source refusing the read (a GitHub draft, which has none)
60    /// included, so showing such a task is a partial answer that says why. An empty list says
61    /// the source has comments and this task holds none, which is a different thing to tell a
62    /// reader.
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub comments: Option<Vec<Comment>>,
65}
66
67impl Engine {
68    /// One task by its qualified id, with its comments when its source has them.
69    ///
70    /// The task is read exactly as [`task`](Self::task) reads it, and the comments are read
71    /// only once the task was found — a source declaring no comments is never asked.
72    ///
73    /// # Errors
74    ///
75    /// As [`task`](Self::task). A comment read that fails is not an error: it lands in the
76    /// response's `errors` beside the task that was read.
77    pub async fn task_detail(&self, id: &GlobalId) -> Result<TaskDetail, EngineError> {
78        let mut response = self.task(id).await?;
79        let source = match self.configured(&id.source) {
80            Some(ConfiguredSource::Ready(source))
81                if !response.items.is_empty()
82                    && source.source().capabilities().comments.is_native() =>
83            {
84                source
85            }
86            _ => {
87                return Ok(TaskDetail {
88                    response,
89                    comments: None,
90                });
91            }
92        };
93        let comments = match walk(source, &id.native).await {
94            Ok(comments) => comments,
95            Err(error) => {
96                response.errors.push(SourceFailure {
97                    source: source.name().clone(),
98                    error,
99                });
100                None
101            }
102        };
103        Ok(TaskDetail { response, comments })
104    }
105
106    /// Every comment on one task, oldest first.
107    ///
108    /// # Errors
109    ///
110    /// Returns [`EngineError::NoComments`] for a source whose tasks have none, before
111    /// anything is read; [`EngineError::NoSuchTask`] when the source holds no such task; and
112    /// [`EngineError::SourceFailed`] when the source could not answer.
113    pub async fn comments(&self, task: &GlobalId) -> Result<CommentList, EngineError> {
114        let source = self.commented(&task.source)?;
115        match walk(source, &task.native).await {
116            Ok(Some(comments)) => Ok(CommentList { comments }),
117            Ok(None) => Err(no_such_task(task)),
118            Err(error) => Err(failed(source, error)),
119        }
120    }
121
122    /// Add one comment to a task, answering with the comment as its source now holds it.
123    ///
124    /// # Errors
125    ///
126    /// As [`comments`](Self::comments), plus [`EngineError::CommentsNotWritable`] for a
127    /// source whose comments cannot be written, before anything is written.
128    pub async fn add_comment(
129        &self,
130        task: &GlobalId,
131        comment: &NewComment,
132    ) -> Result<Comment, EngineError> {
133        let source = self.writable_comments(&task.source)?;
134        match source.source().add_comment(&task.native, comment).await {
135            Ok(Some(added)) => Ok(added),
136            Ok(None) => Err(no_such_task(task)),
137            Err(error) => Err(failed(source, error)),
138        }
139    }
140
141    /// Replace one comment's body, answering with the comment as its source now holds it.
142    ///
143    /// # Errors
144    ///
145    /// As [`add_comment`](Self::add_comment), plus [`EngineError::NoSuchComment`] when the
146    /// task has no comment under `comment`.
147    pub async fn edit_comment(
148        &self,
149        task: &GlobalId,
150        comment: &NativeId,
151        body: &CommentBody,
152    ) -> Result<Comment, EngineError> {
153        let source = self.writable_comments(&task.source)?;
154        match source
155            .source()
156            .edit_comment(&task.native, comment, body)
157            .await
158        {
159            Ok(Some(edited)) => Ok(edited),
160            Ok(None) => Err(missing(source, task, comment).await),
161            Err(error) => Err(failed(source, error)),
162        }
163    }
164
165    /// Remove one comment from a task, answering with the id it removed.
166    ///
167    /// # Errors
168    ///
169    /// As [`edit_comment`](Self::edit_comment).
170    pub async fn delete_comment(
171        &self,
172        task: &GlobalId,
173        comment: &NativeId,
174    ) -> Result<DeletedComment, EngineError> {
175        let source = self.writable_comments(&task.source)?;
176        match source.source().delete_comment(&task.native, comment).await {
177            Ok(Some(deleted)) => Ok(DeletedComment { deleted }),
178            Ok(None) => Err(missing(source, task, comment).await),
179            Err(error) => Err(failed(source, error)),
180        }
181    }
182
183    /// The configured source called `name`, in whichever state it is in.
184    fn configured(&self, name: &SourceName) -> Option<&ConfiguredSource> {
185        self.sources.iter().find(|source| source.name() == name)
186    }
187
188    /// The built source called `name`, when its tasks have comments.
189    fn commented(&self, name: &SourceName) -> Result<&ResolvedSource, EngineError> {
190        let name = self.known(name)?;
191        match self.configured(&name) {
192            Some(ConfiguredSource::Ready(source)) => {
193                if source.source().capabilities().comments.is_native() {
194                    Ok(source)
195                } else {
196                    Err(EngineError::NoComments {
197                        name: name.to_string(),
198                        kind: source.kind().to_owned(),
199                    })
200                }
201            }
202            Some(ConfiguredSource::Unavailable(source)) => Err(EngineError::SourceUnavailable {
203                name: name.to_string(),
204                error: source.error().clone(),
205            }),
206            // `known` has just said a source by this name is configured, and `configured`
207            // reads the same list it read.
208            None => Err(EngineError::NoSources),
209        }
210    }
211
212    /// The built source called `name`, when its tasks have comments it can write.
213    fn writable_comments(&self, name: &SourceName) -> Result<&ResolvedSource, EngineError> {
214        let source = self.commented(name)?;
215        if source.source().writes().is_supported() {
216            return Ok(source);
217        }
218        Err(EngineError::CommentsNotWritable {
219            name: source.name().to_string(),
220            kind: source.kind().to_owned(),
221        })
222    }
223}
224
225/// Every comment on `task`, walked to the end of the source's pages, or `None` when the
226/// source holds no such task.
227///
228/// Each page is asked at the source's own ceiling, and each is held to the two refusals every
229/// pagination loop of this engine owes: a page longer than the one asked for, and a cursor
230/// handed back unchanged. What the walk accumulates is the caller's answer and nothing else.
231async fn walk(
232    source: &ResolvedSource,
233    task: &NativeId,
234) -> Result<Option<Vec<Comment>>, SourceError> {
235    let limit = source.source().capabilities().max_page_size.max(1);
236    let mut comments = Vec::new();
237    let mut cursor: Option<Cursor> = None;
238    loop {
239        let request = PageRequest {
240            cursor: cursor.clone(),
241            limit,
242        };
243        // A task that is gone part way through a walk is gone: reporting the comments read
244        // before it went would describe a task nobody can address any more.
245        let Some(page) = source.source().task_comments(task, &request).await? else {
246            return Ok(None);
247        };
248        fits(page.items.len(), limit)?;
249        unrepeated(
250            page.next.as_ref(),
251            cursor.as_ref(),
252            "walking a task's comments",
253        )?;
254        comments.extend(page.items);
255        match page.next {
256            Some(next) => cursor = Some(next),
257            None => return Ok(Some(comments)),
258        }
259    }
260}
261
262/// Narrow one page of a source's tasks to those with a comment created or last edited at or
263/// after `since`, for a source that does not apply that predicate itself.
264///
265/// This is the one predicate the engine cannot answer from the row: a task does not carry its
266/// comments. So each task the other predicates left to the engine already keep — `local` —
267/// has its comments read, page by page, until one matches or they run out; a task every
268/// other predicate drops is never asked about. What is held is one task's page of comments
269/// at a time, and nothing of it outlives the call. A source whose tasks have no comments at
270/// all holds no comment activity, so none of its tasks is kept and it is asked nothing, and a
271/// task gone from the source by the time its comments are read is gone from the answer.
272///
273/// # Errors
274///
275/// Returns whatever the source returned for a comment read, and the two refusals every
276/// pagination loop of this engine owes.
277pub(super) async fn commented_since(
278    source: &ResolvedSource,
279    local: &super::local::LocalTasks,
280    page: Page<Task>,
281    since: DateTime<Utc>,
282) -> Result<Page<Task>, SourceError> {
283    if !source.source().capabilities().comments.is_native() {
284        return Ok(Page {
285            items: Vec::new(),
286            next: page.next,
287        });
288    }
289    let query = TaskQuery {
290        commented_since: Some(since),
291        ..TaskQuery::default()
292    };
293    let mut kept = Vec::new();
294    for task in page.items {
295        if local.keeps(&task) && any_comment_matches(source, &task.id, &query).await? {
296            kept.push(task);
297        }
298    }
299    Ok(Page {
300        items: kept,
301        next: page.next,
302    })
303}
304
305/// Whether one of `task`'s comments satisfies `query`'s comment activity, walking the source's
306/// comment pages only as far as the first that does.
307async fn any_comment_matches(
308    source: &ResolvedSource,
309    task: &NativeId,
310    query: &TaskQuery,
311) -> Result<bool, SourceError> {
312    let limit = source.source().capabilities().max_page_size.max(1);
313    let mut cursor: Option<Cursor> = None;
314    loop {
315        let request = PageRequest {
316            cursor: cursor.clone(),
317            limit,
318        };
319        let Some(page) = source.source().task_comments(task, &request).await? else {
320            return Ok(false);
321        };
322        fits(page.items.len(), limit)?;
323        unrepeated(
324            page.next.as_ref(),
325            cursor.as_ref(),
326            "walking a task's comments",
327        )?;
328        if query.comments_match(&page.items) {
329            return Ok(true);
330        }
331        match page.next {
332            Some(next) => cursor = Some(next),
333            None => return Ok(false),
334        }
335    }
336}
337
338/// Which of the two things an edit or a delete named was not there.
339///
340/// Asked only once the source has already said one of them is missing, so a comment verb
341/// that succeeds costs its source exactly one call.
342async fn missing(source: &ResolvedSource, task: &GlobalId, comment: &NativeId) -> EngineError {
343    match source.source().get_task(&task.native).await {
344        Ok(Some(_)) => EngineError::NoSuchComment {
345            task: task.to_string(),
346            comment: comment.to_string(),
347        },
348        Ok(None) => no_such_task(task),
349        Err(error) => failed(source, error),
350    }
351}
352
353fn no_such_task(task: &GlobalId) -> EngineError {
354    EngineError::NoSuchTask {
355        id: task.to_string(),
356    }
357}
358
359fn failed(source: &ResolvedSource, error: SourceError) -> EngineError {
360    EngineError::SourceFailed {
361        name: source.name().to_string(),
362        error,
363    }
364}