onetaskgraph_plugin_api/comment.rs
1//! Comments on a task, and the one refusal a source with none answers with.
2//!
3//! A comment is the one part of a task a person adds to in pieces, after the task exists
4//! and without rewriting it: evidence appended to an issue somebody else opened. So it is
5//! not a field of [`Task`](crate::Task) — a copy writes a task whole, and a copy without
6//! `--comments` adds, changes and removes no comment — but a thing of its own, read and
7//! written through its own methods of [`TaskSource`](crate::TaskSource).
8//!
9//! A copy with `--comments` keeps a destination's copies of a source's comments in step,
10//! and each copy it writes names the comment it was copied from as its [`CommentOrigin`]. A
11//! source that can store that natively overrides
12//! [`TaskSource::write_comment_copy`](crate::TaskSource::write_comment_copy); every other one
13//! is handed it as a hidden marker at the end of the body, [`CommentOrigin::mark`], which the
14//! engine strips back out of every comment it reads, [`Comment::unmarked`].
15
16use chrono::{DateTime, Utc};
17use schemars::JsonSchema;
18use serde::{Deserialize, Serialize};
19
20use crate::{NativeId, Page, SourceError, Task};
21
22/// One comment on a task, as its source holds it.
23///
24/// `id` and `body` are always there; every other member is `None` when the source did not
25/// give it, which is not the same as the source saying it is empty.
26#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
27pub struct Comment {
28 /// The comment's own id, exactly as its source issued it.
29 ///
30 /// Opaque, as every [`NativeId`] is, and never qualified: a comment is addressed through
31 /// the qualified id of the task it is on, so its own id needs no source of its own.
32 pub id: NativeId,
33 /// Who wrote it, in the source's own spelling of a person.
34 pub author: Option<String>,
35 /// When the source says it was written.
36 pub created_at: Option<DateTime<Utc>>,
37 /// When the source says it last changed.
38 pub updated_at: Option<DateTime<Utc>>,
39 /// What it says, byte for byte as it was written — a trailing newline included.
40 // llmlint: ignore[invalid_states_unrepresentable] `CommentBody` is the invariant on what this product *writes*, and this is what a source already *holds*: a Linear comment that is only an attachment, or one a person cleared on the service's own page, has an empty body, and refusing it here would fail the whole of a task's comment list over one comment nobody here wrote. The read reports what the source holds; the write is what refuses nothing.
41 pub body: String,
42 /// Where a person can open it.
43 // llmlint: ignore[invalid_states_unrepresentable, boundary_inputs_validated] the reason recorded at `Task::url` in work.rs, at a new site: every entity of this contract carries its web address as `Option<String>`, parsing one would add a URL dependency to the crate AGENTS.md says to keep still, and a comment's address narrowed here alone would describe one thing in two types.
44 pub url: Option<String>,
45 /// The comment this one is a copy of, at its origin — present only on a comment a copy
46 /// wrote, and absent on every comment a person wrote where it is.
47 ///
48 /// On the second hop of a chained copy it still names the *first* origin, never the
49 /// source the copy read it from. Left out of the wire when absent, so a reader written
50 /// before it existed reads exactly what it read before.
51 #[serde(default, skip_serializing_if = "Option::is_none")]
52 pub origin: Option<CommentOrigin>,
53}
54
55/// Where a copied comment came from: the comment as its origin holds it.
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
57pub struct CommentOrigin {
58 /// The task the comment is on at its origin, qualified: `<source>:<native task id>`.
59 // llmlint: ignore[invalid_states_unrepresentable] A qualified id is `onetaskgraph-core`'s `GlobalId`, which this crate cannot name (a plugin never sees one — AGENTS.md, "The plugin contract"); `TaskRef` admits a bare id, which this must never be. The engine is the one writer, and it writes a qualified id's own rendering.
60 pub task: String,
61 /// The comment's own id at its origin.
62 pub id: NativeId,
63 /// Who wrote it there, in that source's spelling of a person.
64 pub author: Option<String>,
65 /// When the origin says it was written.
66 pub created_at: Option<DateTime<Utc>>,
67 /// When the origin says it last changed.
68 pub updated_at: Option<DateTime<Utc>>,
69 /// Where a person can open it at its origin.
70 // llmlint: ignore[invalid_states_unrepresentable, boundary_inputs_validated] the reason recorded at `Comment::url` above, at a new site.
71 pub url: Option<String>,
72}
73
74/// The opening of the marker [`CommentOrigin::mark`] appends to a body.
75const MARKER_OPEN: &str = "<!-- onetaskgraph.comment_origin `";
76/// The close of that marker.
77const MARKER_CLOSE: &str = "` -->";
78/// What separates the marker from the body it follows.
79const MARKER_SEPARATOR: &str = "\n\n";
80
81impl CommentOrigin {
82 /// Whether `task` is a qualified task id — `<source>:<native task id>`, the source a valid
83 /// source name and the native id not empty — and so can be an origin's [`task`](Self::task).
84 ///
85 /// What a reader holds to before it takes a stored origin as one: the origin's task decides
86 /// which copies a sync owns, edits and deletes, and which comments it never sends back, so
87 /// one naming no task is no origin at all and is read as the text it was stored as.
88 #[must_use]
89 pub fn is_qualified_task(task: &str) -> bool {
90 task.split_once(':').is_some_and(|(source, native)| {
91 !native.is_empty() && crate::SourceName::new(source).is_ok()
92 })
93 }
94
95 /// This origin, refused as [`SourceError::Malformed`] when its task is not a qualified task
96 /// id — what every writer of a copied comment holds an origin to before it writes one, since
97 /// such an origin would be stored and then read back as none.
98 ///
99 /// # Errors
100 ///
101 /// [`SourceError::Malformed`], naming the task it was given.
102 pub fn qualified(&self) -> Result<&Self, SourceError> {
103 if Self::is_qualified_task(&self.task) {
104 Ok(self)
105 } else {
106 Err(SourceError::Malformed {
107 message: format!(
108 "a copied comment's origin names its task as {:?}, which is not a \
109 qualified task id `<source>:<native task id>`",
110 self.task
111 ),
112 })
113 }
114 }
115
116 /// `body` with this origin appended as a hidden marker, for a source that cannot store a
117 /// comment's origin, author or times natively.
118 ///
119 /// One line, an HTML comment whose payload is compact JSON inside a code span —
120 /// ``<!-- onetaskgraph.comment_origin `{…}` -->`` — because that is the spelling both
121 /// hosted services this product drives keep byte for byte (the Linear plugin's module
122 /// documentation records what Linear does to every other one). `<`, `>` and `` ` `` are
123 /// escaped inside the JSON, where the escape means the same character, so no value can
124 /// close the marker early.
125 #[must_use]
126 pub fn mark(&self, body: &str) -> String {
127 let encoded = serde_json::to_string(self)
128 .expect("a comment origin serialises")
129 .replace('<', "\\u003c")
130 .replace('>', "\\u003e")
131 .replace('`', "\\u0060");
132 format!("{body}{MARKER_SEPARATOR}{MARKER_OPEN}{encoded}{MARKER_CLOSE}")
133 }
134}
135
136impl CommentOrigin {
137 /// `body`, carrying over the hidden marker the comment it replaces ends in — `held`, that
138 /// comment's body exactly as its source holds it — when `body` ends in none of its own.
139 ///
140 /// What a source that keeps a copied comment's origin as the marker answers an edit with:
141 /// an edit changes what a comment says and nothing else, so a comment a copy wrote is still
142 /// that copy afterwards, and the next copy keeps it in step rather than writing another
143 /// beside it. A `body` that ends in a marker of its own — a copy rewriting its own comment —
144 /// is left as it is.
145 ///
146 /// # Errors
147 ///
148 /// None in practice, because the body only grows: [`SourceError::Refused`] is what
149 /// [`CommentBody::new`] answers an empty text with.
150 pub fn kept(held: &str, body: &CommentBody) -> Result<CommentBody, SourceError> {
151 let origin_of = |text: &str| {
152 Comment {
153 id: NativeId(String::new()),
154 author: None,
155 created_at: None,
156 updated_at: None,
157 body: text.to_owned(),
158 url: None,
159 origin: None,
160 }
161 .unmarked()
162 .origin
163 };
164 match (origin_of(held), origin_of(body.as_str())) {
165 (Some(origin), None) => CommentBody::new(origin.mark(body.as_str())),
166 _ => Ok(body.clone()),
167 }
168 }
169}
170
171impl Comment {
172 /// This comment with a marker [`CommentOrigin::mark`] wrote taken out of its body and
173 /// reported as its [`origin`](Self::origin); unchanged when it carries an origin already
174 /// or its body ends in no such marker.
175 ///
176 /// A marker that does not parse is left in the body as text: it is not one this product
177 /// wrote, and reading it as an origin would put words in a person's comment. An origin a
178 /// source reports natively — a stdio peer's, say — is held to the same test: one naming no
179 /// qualified task is dropped, so the comment reads as one written where it is, which a copy
180 /// never edits or deletes.
181 #[must_use]
182 pub fn unmarked(mut self) -> Self {
183 if let Some(origin) = &self.origin {
184 if !CommentOrigin::is_qualified_task(&origin.task) {
185 self.origin = None;
186 }
187 return self;
188 }
189 let trimmed = self.body.trim_end();
190 let Some(open) = trimmed.rfind(MARKER_OPEN) else {
191 return self;
192 };
193 let Some(encoded) = trimmed[open + MARKER_OPEN.len()..].strip_suffix(MARKER_CLOSE) else {
194 return self;
195 };
196 let Ok(origin) = serde_json::from_str::<CommentOrigin>(encoded) else {
197 return self;
198 };
199 if !CommentOrigin::is_qualified_task(&origin.task) {
200 return self;
201 }
202 let before = &self.body[..open];
203 let body = before.strip_suffix(MARKER_SEPARATOR).unwrap_or(before);
204 self.body = body.to_owned();
205 self.origin = Some(origin);
206 self
207 }
208}
209
210/// One comment a copy writes: what it says, and the comment it is a copy of.
211#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
212pub struct CommentCopy {
213 /// What it says, as its origin says it.
214 pub body: CommentBody,
215 /// The comment it is a copy of.
216 pub origin: CommentOrigin,
217}
218
219/// What a comment says: anything at all, except nothing.
220///
221/// A newtype rather than a `String` checked by whoever happens to read it, because an empty
222/// comment is refused by every source this product drives and by the command line before
223/// any of them: a value of this type is one a source can be handed without asking again.
224/// Everything else about the text is kept exactly — no trimming, no newline normalisation —
225/// because a body quoting a command or a stack trace is only useful byte for byte.
226#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
227#[serde(try_from = "String", into = "String")]
228pub struct CommentBody(String);
229
230impl CommentBody {
231 /// Wrap `text` as a comment body.
232 ///
233 /// # Errors
234 ///
235 /// Returns [`SourceError::Refused`] when `text` is empty, saying so.
236 pub fn new(text: impl Into<String>) -> Result<Self, SourceError> {
237 let text = text.into();
238 if text.is_empty() {
239 return Err(SourceError::Refused {
240 message: "a comment body cannot be empty; write what the comment says".to_owned(),
241 });
242 }
243 Ok(Self(text))
244 }
245
246 /// The text, exactly as it was given.
247 #[must_use]
248 pub fn as_str(&self) -> &str {
249 &self.0
250 }
251}
252
253impl TryFrom<String> for CommentBody {
254 type Error = String;
255
256 fn try_from(text: String) -> Result<Self, Self::Error> {
257 Self::new(text).map_err(|error| error.to_string())
258 }
259}
260
261impl From<CommentBody> for String {
262 fn from(body: CommentBody) -> Self {
263 body.0
264 }
265}
266
267/// One comment to add to a task.
268#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
269pub struct NewComment {
270 /// What it says.
271 pub body: CommentBody,
272 /// Who wrote it, when the caller says.
273 ///
274 /// A source that records the author itself — a hosted service that knows which account
275 /// is signed in — refuses a comment carrying one rather than dropping it, naming why:
276 /// quietly posting under another name than the one asked for is the one wrong answer
277 /// here.
278 // llmlint: ignore[invalid_states_unrepresentable] an author is the source's own spelling of a person — a login, a display name, a free-form name in a Markdown file — so there is no narrower type every source could agree on; each source refuses what it cannot record, naming it, which is the rule every write of this contract follows.
279 #[serde(default)]
280 pub author: Option<String>,
281}
282
283/// One task as [`TaskSource::get_task_details`](crate::TaskSource::get_task_details) reads it:
284/// the task, and the first page of its comments when they were asked for.
285///
286/// The comments are a result of their own rather than part of the task's, because the two
287/// can fail apart: a task that was read and whose comments could not be — a board draft,
288/// which has none — is still a task a caller is owed, with the comment failure beside it.
289#[derive(Debug, Clone, PartialEq)]
290pub struct TaskDetailRead {
291 /// The task, exactly as [`TaskSource::get_task`](crate::TaskSource::get_task) answers it.
292 pub task: Task,
293 /// The first page of its comments, oldest first: `None` when the read did not ask for
294 /// them, and otherwise exactly what
295 /// [`TaskSource::task_comments`](crate::TaskSource::task_comments) answers for that page
296 /// — `Ok(None)` included, for a task gone between the two halves of a read that made two.
297 pub comments: Option<Result<Option<Page<Comment>>, SourceError>>,
298}
299
300/// The refusal a source with no comments answers a comment call with.
301///
302/// Spelled once beside [`unwritable`](crate::unwritable) and
303/// [`documentless`](crate::documentless), for the reason they are: a source saying it does not
304/// have that side of the contract at all refuses in the same words whichever plugin it is,
305/// and cannot describe something different from the engine's own message about it.
306#[must_use]
307pub fn commentless(kind: &str) -> SourceError {
308 SourceError::Refused {
309 message: format!("the {kind} plugin has no comments"),
310 }
311}