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
171/// Who wrote a comment, as the source that holds it can tell: a person, a bot — an
172/// integration, an app, an automation acting on its own — or neither, when the source cannot
173/// say. Decided by the plugin, through
174/// [`TaskSource::comment_author_kind`](crate::TaskSource::comment_author_kind).
175#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
176#[serde(rename_all = "kebab-case")]
177pub enum AuthorKind {
178 /// A person.
179 Person,
180 /// A bot, an integration or an app.
181 Bot,
182 /// The source cannot say — no author at all, a deleted account.
183 Unknown,
184}
185
186impl AuthorKind {
187 /// The kind an author spelled `author` is by the convention every source shares unless it
188 /// knows better: a name ending `[bot]` — how GitHub spells an app, and how a comment copied
189 /// from one keeps it — is a bot, any other name a person, and no name unknown.
190 #[must_use]
191 pub fn of_name(author: Option<&str>) -> Self {
192 match author {
193 Some(name) if name.ends_with("[bot]") => Self::Bot,
194 Some(_) => Self::Person,
195 None => Self::Unknown,
196 }
197 }
198}
199
200impl Comment {
201 /// This comment with a marker [`CommentOrigin::mark`] wrote taken out of its body and
202 /// reported as its [`origin`](Self::origin); unchanged when it carries an origin already
203 /// or its body ends in no such marker.
204 ///
205 /// A marker that does not parse is left in the body as text: it is not one this product
206 /// wrote, and reading it as an origin would put words in a person's comment. An origin a
207 /// source reports natively — a stdio peer's, say — is held to the same test: one naming no
208 /// qualified task is dropped, so the comment reads as one written where it is, which a copy
209 /// never edits or deletes.
210 #[must_use]
211 pub fn unmarked(mut self) -> Self {
212 if let Some(origin) = &self.origin {
213 if !CommentOrigin::is_qualified_task(&origin.task) {
214 self.origin = None;
215 }
216 return self;
217 }
218 let trimmed = self.body.trim_end();
219 let Some(open) = trimmed.rfind(MARKER_OPEN) else {
220 return self;
221 };
222 let Some(encoded) = trimmed[open + MARKER_OPEN.len()..].strip_suffix(MARKER_CLOSE) else {
223 return self;
224 };
225 let Ok(origin) = serde_json::from_str::<CommentOrigin>(encoded) else {
226 return self;
227 };
228 if !CommentOrigin::is_qualified_task(&origin.task) {
229 return self;
230 }
231 let before = &self.body[..open];
232 let body = before.strip_suffix(MARKER_SEPARATOR).unwrap_or(before);
233 self.body = body.to_owned();
234 self.origin = Some(origin);
235 self
236 }
237}
238
239/// One comment a copy writes: what it says, and the comment it is a copy of.
240#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
241pub struct CommentCopy {
242 /// What it says, as its origin says it.
243 pub body: CommentBody,
244 /// The comment it is a copy of.
245 pub origin: CommentOrigin,
246}
247
248/// What a comment says: anything at all, except nothing.
249///
250/// A newtype rather than a `String` checked by whoever happens to read it, because an empty
251/// comment is refused by every source this product drives and by the command line before
252/// any of them: a value of this type is one a source can be handed without asking again.
253/// Everything else about the text is kept exactly — no trimming, no newline normalisation —
254/// because a body quoting a command or a stack trace is only useful byte for byte.
255#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
256#[serde(try_from = "String", into = "String")]
257pub struct CommentBody(String);
258
259impl CommentBody {
260 /// Wrap `text` as a comment body.
261 ///
262 /// # Errors
263 ///
264 /// Returns [`SourceError::Refused`] when `text` is empty, saying so.
265 pub fn new(text: impl Into<String>) -> Result<Self, SourceError> {
266 let text = text.into();
267 if text.is_empty() {
268 return Err(SourceError::Refused {
269 message: "a comment body cannot be empty; write what the comment says".to_owned(),
270 });
271 }
272 Ok(Self(text))
273 }
274
275 /// The text, exactly as it was given.
276 #[must_use]
277 pub fn as_str(&self) -> &str {
278 &self.0
279 }
280}
281
282impl TryFrom<String> for CommentBody {
283 type Error = String;
284
285 fn try_from(text: String) -> Result<Self, Self::Error> {
286 Self::new(text).map_err(|error| error.to_string())
287 }
288}
289
290impl From<CommentBody> for String {
291 fn from(body: CommentBody) -> Self {
292 body.0
293 }
294}
295
296/// One comment to add to a task.
297#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
298pub struct NewComment {
299 /// What it says.
300 pub body: CommentBody,
301 /// Who wrote it, when the caller says.
302 ///
303 /// A source that records the author itself — a hosted service that knows which account
304 /// is signed in — refuses a comment carrying one rather than dropping it, naming why:
305 /// quietly posting under another name than the one asked for is the one wrong answer
306 /// here.
307 // 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.
308 #[serde(default)]
309 pub author: Option<String>,
310}
311
312/// One task as [`TaskSource::get_task_details`](crate::TaskSource::get_task_details) reads it:
313/// the task, and the first page of its comments when they were asked for.
314///
315/// The comments are a result of their own rather than part of the task's, because the two
316/// can fail apart: a task that was read and whose comments could not be — a board draft,
317/// which has none — is still a task a caller is owed, with the comment failure beside it.
318#[derive(Debug, Clone, PartialEq)]
319pub struct TaskDetailRead {
320 /// The task, exactly as [`TaskSource::get_task`](crate::TaskSource::get_task) answers it.
321 pub task: Task,
322 /// The first page of its comments, oldest first: `None` when the read did not ask for
323 /// them, and otherwise exactly what
324 /// [`TaskSource::task_comments`](crate::TaskSource::task_comments) answers for that page
325 /// — `Ok(None)` included, for a task gone between the two halves of a read that made two.
326 pub comments: Option<Result<Option<Page<Comment>>, SourceError>>,
327}
328
329/// The refusal a source with no comments answers a comment call with.
330///
331/// Spelled once beside [`unwritable`](crate::unwritable) and
332/// [`documentless`](crate::documentless), for the reason they are: a source saying it does not
333/// have that side of the contract at all refuses in the same words whichever plugin it is,
334/// and cannot describe something different from the engine's own message about it.
335#[must_use]
336pub fn commentless(kind: &str) -> SourceError {
337 SourceError::Refused {
338 message: format!("the {kind} plugin has no comments"),
339 }
340}