linear_api/comments.rs
1//! Issue comment threads: read an issue's comments and create, update, or
2//! delete comments (threading included).
3//!
4//! Entry point: [`LinearClient::comments`].
5//!
6//! ```no_run
7//! # async fn run() -> linear_api::Result<()> {
8//! use linear_api::comments::CommentListRequest;
9//! use linear_api::{IssueRef, LinearClient};
10//!
11//! let client = LinearClient::from_env()?;
12//! // The "last 5 comments" deep-view call shape:
13//! let page = client
14//! .comments()
15//! .list_for_issue(
16//! IssueRef::identifier("ENG-123"),
17//! CommentListRequest::builder().first(5).build(),
18//! )
19//! .await?;
20//! for comment in &page.nodes {
21//! println!("{}", comment.body);
22//! }
23//! # Ok(()) }
24//! ```
25
26use bon::Builder;
27use futures::Stream;
28use serde::{Deserialize, Serialize};
29use time::OffsetDateTime;
30
31use crate::client::LinearClient;
32use crate::error::{Error, Result};
33use crate::ids::{CommentId, IssueRef};
34use crate::pagination::{Page, PageInfo};
35use crate::types::{UserRef, ensure_success};
36
37/// Appends the canonical `CommentFields` fragment (and the `UserRefFields`
38/// fragment it depends on) to an operation. Field sets MUST stay in sync with
39/// [`Comment`] and [`UserRef`].
40macro_rules! with_comment_fragments {
41 ($op:literal) => {
42 concat!(
43 $op,
44 " fragment CommentFields on Comment {",
45 " id body url user { ...UserRefFields }",
46 " createdAt editedAt resolvedAt parent { id } }",
47 " fragment UserRefFields on User { id name displayName }",
48 )
49 };
50}
51
52const COMMENTS_FOR_ISSUE: &str = with_comment_fragments!(
53 "query CommentsForIssue($id: String!, $first: Int, $after: String) { \
54 issue(id: $id) { comments(first: $first, after: $after) { \
55 nodes { ...CommentFields } \
56 pageInfo { hasNextPage hasPreviousPage startCursor endCursor } } } }"
57);
58
59const COMMENT_CREATE: &str = with_comment_fragments!(
60 "mutation CommentCreate($input: CommentCreateInput!) { \
61 commentCreate(input: $input) { success comment { ...CommentFields } } }"
62);
63
64const COMMENT_UPDATE: &str = with_comment_fragments!(
65 "mutation CommentUpdate($id: String!, $input: CommentUpdateInput!) { \
66 commentUpdate(id: $id, input: $input) { success comment { ...CommentFields } } }"
67);
68
69const COMMENT_DELETE: &str =
70 "mutation CommentDelete($id: String!) { commentDelete(id: $id) { success } }";
71
72pub(crate) const DOCUMENTS: &[(&str, &str)] = &[
73 ("CommentsForIssue", COMMENTS_FOR_ISSUE),
74 ("CommentCreate", COMMENT_CREATE),
75 ("CommentUpdate", COMMENT_UPDATE),
76 ("CommentDelete", COMMENT_DELETE),
77];
78
79/// One comment on an issue.
80///
81/// Wire shape: the canonical fragment
82/// `fragment CommentFields on Comment { id body url user { ...UserRefFields }
83/// createdAt editedAt resolvedAt parent { id } }`.
84#[derive(Debug, Clone, Deserialize)]
85#[serde(rename_all = "camelCase")]
86#[non_exhaustive]
87pub struct Comment {
88 /// Comment ID.
89 pub id: CommentId,
90 /// The comment content, in markdown.
91 pub body: String,
92 /// URL of the comment on linear.app.
93 pub url: String,
94 /// The author. `None` for some integration-authored comments (e.g. bots
95 /// posting via `createAsUser`).
96 #[serde(default)]
97 pub user: Option<UserRef>,
98 /// When the comment was created.
99 #[serde(with = "time::serde::rfc3339")]
100 pub created_at: OffsetDateTime,
101 /// When the comment was last edited, if ever.
102 #[serde(default, with = "time::serde::rfc3339::option")]
103 pub edited_at: Option<OffsetDateTime>,
104 /// When the comment thread was resolved, if it has been.
105 #[serde(default, with = "time::serde::rfc3339::option")]
106 pub resolved_at: Option<OffsetDateTime>,
107 /// The parent comment when this comment is a threaded reply.
108 #[serde(default)]
109 pub parent: Option<CommentParent>,
110}
111
112/// Reference to the parent of a threaded comment.
113#[derive(Debug, Clone, Deserialize)]
114#[serde(rename_all = "camelCase")]
115#[non_exhaustive]
116pub struct CommentParent {
117 /// Parent comment ID.
118 pub id: CommentId,
119}
120
121/// Pagination controls for [`CommentsService::list_for_issue`].
122///
123/// For the "latest N comments" pattern pass `first: N`; ordering follows
124/// Linear's default connection order (`createdAt`).
125///
126/// ```
127/// use linear_api::comments::CommentListRequest;
128///
129/// let req = CommentListRequest::builder().first(5).build();
130/// assert_eq!(req.first, Some(5));
131/// ```
132#[derive(Debug, Clone, Default, Serialize, Builder)]
133#[serde(rename_all = "camelCase")]
134#[non_exhaustive]
135pub struct CommentListRequest {
136 /// Page size (server default 50). Page size multiplies query complexity;
137 /// prefer modest values.
138 #[serde(skip_serializing_if = "Option::is_none")]
139 pub first: Option<i32>,
140 /// Cursor to resume from — a previous page's
141 /// [`end_cursor`](crate::PageInfo::end_cursor).
142 #[serde(skip_serializing_if = "Option::is_none")]
143 #[builder(into)]
144 pub after: Option<String>,
145}
146
147/// Input for [`CommentsService::create`] (`commentCreate`).
148///
149/// ```
150/// use linear_api::comments::CommentCreateInput;
151///
152/// let input = CommentCreateInput::builder()
153/// .issue_id("ENG-123")
154/// .body("Deployed to staging.".to_owned())
155/// .build();
156/// assert_eq!(input.issue_id, "ENG-123");
157/// ```
158#[derive(Debug, Clone, Serialize, Builder)]
159#[serde(rename_all = "camelCase")]
160#[non_exhaustive]
161pub struct CommentCreateInput {
162 /// The issue to comment on. Pass `IssueId.to_string()` or an `"ENG-123"`
163 /// identifier string; the UUID is the safest form.
164 #[builder(into)]
165 pub issue_id: String,
166 /// The comment content, in markdown.
167 pub body: String,
168 /// Parent comment to nest this comment under (threading).
169 #[serde(skip_serializing_if = "Option::is_none")]
170 pub parent_id: Option<CommentId>,
171 /// Attribution label for bot-style comments: create the comment as a
172 /// user with this name (only honored for OAuth apps in `actor=app` mode).
173 #[serde(skip_serializing_if = "Option::is_none")]
174 pub create_as_user: Option<String>,
175}
176
177/// Wire input for `commentUpdate` — internal; the public surface is
178/// [`CommentsService::update`]'s `body` parameter.
179#[derive(Serialize)]
180struct CommentUpdateInput {
181 body: String,
182}
183
184/// Comment operations, obtained via [`LinearClient::comments`].
185///
186/// ```no_run
187/// # async fn run() -> linear_api::Result<()> {
188/// let client = linear_api::LinearClient::from_env()?;
189/// let comment = client
190/// .comments()
191/// .create_on(linear_api::IssueRef::identifier("ENG-123"), "On it.")
192/// .await?;
193/// println!("created {}", comment.url);
194/// # Ok(()) }
195/// ```
196#[derive(Clone, Copy)]
197pub struct CommentsService<'a> {
198 client: &'a LinearClient,
199}
200
201impl LinearClient {
202 /// Comment operations: read an issue's thread, create, update, delete.
203 ///
204 /// ```no_run
205 /// # async fn run() -> linear_api::Result<()> {
206 /// # let client = linear_api::LinearClient::from_env()?;
207 /// let comments = client.comments();
208 /// # Ok(()) }
209 /// ```
210 pub fn comments(&self) -> CommentsService<'_> {
211 CommentsService { client: self }
212 }
213}
214
215impl<'a> CommentsService<'a> {
216 /// Fetches one page of an issue's comment thread
217 /// (`issue(id:) { comments }`).
218 ///
219 /// For the "latest N comments" pattern pass `first: N` on the request;
220 /// ordering follows Linear's default connection order (`createdAt`).
221 ///
222 /// ```no_run
223 /// # async fn run() -> linear_api::Result<()> {
224 /// # let client = linear_api::LinearClient::from_env()?;
225 /// use linear_api::IssueRef;
226 /// use linear_api::comments::CommentListRequest;
227 ///
228 /// let page = client
229 /// .comments()
230 /// .list_for_issue(
231 /// IssueRef::identifier("ENG-123"),
232 /// CommentListRequest::builder().first(5).build(),
233 /// )
234 /// .await?;
235 /// # Ok(()) }
236 /// ```
237 pub async fn list_for_issue(
238 &self,
239 issue: impl Into<IssueRef>,
240 req: CommentListRequest,
241 ) -> Result<Page<Comment>> {
242 #[derive(Serialize)]
243 struct Vars<'v> {
244 id: &'v str,
245 #[serde(skip_serializing_if = "Option::is_none")]
246 first: Option<i32>,
247 #[serde(skip_serializing_if = "Option::is_none")]
248 after: Option<&'v str>,
249 }
250 #[derive(Deserialize)]
251 struct Data {
252 issue: IssueNode,
253 }
254 #[derive(Deserialize)]
255 struct IssueNode {
256 comments: Connection,
257 }
258 #[derive(Deserialize)]
259 #[serde(rename_all = "camelCase")]
260 struct Connection {
261 nodes: Vec<Comment>,
262 page_info: PageInfo,
263 }
264
265 let issue = issue.into();
266 let data: Data = self
267 .client
268 .query(
269 "CommentsForIssue",
270 COMMENTS_FOR_ISSUE,
271 Vars {
272 id: issue.api_string(),
273 first: req.first,
274 after: req.after.as_deref(),
275 },
276 )
277 .await?;
278 Ok(Page {
279 nodes: data.issue.comments.nodes,
280 page_info: data.issue.comments.page_info,
281 })
282 }
283
284 /// Lazily streams an issue's whole comment thread across pages via
285 /// [`paginate`](crate::paginate).
286 ///
287 /// `req.first` controls the page size; a pre-set `req.after` seeds the
288 /// first page's cursor.
289 ///
290 /// ```no_run
291 /// # async fn run() -> linear_api::Result<()> {
292 /// # let client = linear_api::LinearClient::from_env()?;
293 /// use futures::TryStreamExt;
294 /// use linear_api::IssueRef;
295 /// use linear_api::comments::CommentListRequest;
296 ///
297 /// let comments: Vec<_> = client
298 /// .comments()
299 /// .list_for_issue_stream(
300 /// IssueRef::identifier("ENG-123"),
301 /// CommentListRequest::builder().build(),
302 /// )
303 /// .try_collect()
304 /// .await?;
305 /// # Ok(()) }
306 /// ```
307 pub fn list_for_issue_stream(
308 &self,
309 issue: impl Into<IssueRef>,
310 req: CommentListRequest,
311 ) -> impl Stream<Item = Result<Comment>> + 'a {
312 let service = *self;
313 // Resolve to an owned ref before building the closure so the stream
314 // borrows nothing but the client.
315 let issue = issue.into();
316 crate::pagination::paginate(move |cursor| {
317 let mut req = req.clone();
318 if cursor.is_some() {
319 req.after = cursor;
320 }
321 let issue = issue.clone();
322 async move { service.list_for_issue(issue, req).await }
323 })
324 }
325
326 /// Creates a comment (`commentCreate`). Thread replies via
327 /// [`parent_id`](CommentCreateInput::parent_id).
328 ///
329 /// ```no_run
330 /// # async fn run() -> linear_api::Result<()> {
331 /// # let client = linear_api::LinearClient::from_env()?;
332 /// use linear_api::comments::CommentCreateInput;
333 ///
334 /// let comment = client
335 /// .comments()
336 /// .create(
337 /// CommentCreateInput::builder()
338 /// .issue_id("ENG-123")
339 /// .body("Run finished: all green.".to_owned())
340 /// .build(),
341 /// )
342 /// .await?;
343 /// # Ok(()) }
344 /// ```
345 pub async fn create(&self, input: CommentCreateInput) -> Result<Comment> {
346 #[derive(Serialize)]
347 struct Vars {
348 input: CommentCreateInput,
349 }
350 #[derive(Deserialize)]
351 #[serde(rename_all = "camelCase")]
352 struct Data {
353 comment_create: CommentPayload,
354 }
355
356 let data: Data = self
357 .client
358 .mutation("CommentCreate", COMMENT_CREATE, Vars { input })
359 .await?;
360 data.comment_create.into_comment("CommentCreate")
361 }
362
363 /// Convenience: creates a plain markdown comment on an issue.
364 ///
365 /// ```no_run
366 /// # async fn run() -> linear_api::Result<()> {
367 /// # let client = linear_api::LinearClient::from_env()?;
368 /// use linear_api::IssueRef;
369 ///
370 /// client
371 /// .comments()
372 /// .create_on(IssueRef::identifier("ENG-123"), "On it.")
373 /// .await?;
374 /// # Ok(()) }
375 /// ```
376 pub async fn create_on(
377 &self,
378 issue: impl Into<IssueRef>,
379 body: impl Into<String>,
380 ) -> Result<Comment> {
381 let issue = issue.into();
382 self.create(
383 CommentCreateInput::builder()
384 .issue_id(issue.api_string())
385 .body(body.into())
386 .build(),
387 )
388 .await
389 }
390
391 /// Replaces a comment's markdown body (`commentUpdate`).
392 ///
393 /// ```no_run
394 /// # async fn run() -> linear_api::Result<()> {
395 /// # let client = linear_api::LinearClient::from_env()?;
396 /// use linear_api::CommentId;
397 ///
398 /// let id = CommentId::new("9d2a…");
399 /// client.comments().update(&id, "Edited: rollout done.").await?;
400 /// # Ok(()) }
401 /// ```
402 pub async fn update(&self, id: &CommentId, body: impl Into<String>) -> Result<Comment> {
403 #[derive(Serialize)]
404 struct Vars<'v> {
405 id: &'v str,
406 input: CommentUpdateInput,
407 }
408 #[derive(Deserialize)]
409 #[serde(rename_all = "camelCase")]
410 struct Data {
411 comment_update: CommentPayload,
412 }
413
414 let data: Data = self
415 .client
416 .mutation(
417 "CommentUpdate",
418 COMMENT_UPDATE,
419 Vars {
420 id: id.as_str(),
421 input: CommentUpdateInput { body: body.into() },
422 },
423 )
424 .await?;
425 data.comment_update.into_comment("CommentUpdate")
426 }
427
428 /// Deletes a comment (`commentDelete`).
429 ///
430 /// ```no_run
431 /// # async fn run() -> linear_api::Result<()> {
432 /// # let client = linear_api::LinearClient::from_env()?;
433 /// use linear_api::CommentId;
434 ///
435 /// let id = CommentId::new("9d2a…");
436 /// client.comments().delete(&id).await?;
437 /// # Ok(()) }
438 /// ```
439 pub async fn delete(&self, id: &CommentId) -> Result<()> {
440 #[derive(Serialize)]
441 struct Vars<'v> {
442 id: &'v str,
443 }
444 #[derive(Deserialize)]
445 #[serde(rename_all = "camelCase")]
446 struct Data {
447 comment_delete: DeletePayload,
448 }
449 #[derive(Deserialize)]
450 struct DeletePayload {
451 success: bool,
452 }
453
454 let data: Data = self
455 .client
456 .mutation("CommentDelete", COMMENT_DELETE, Vars { id: id.as_str() })
457 .await?;
458 ensure_success("CommentDelete", data.comment_delete.success)
459 }
460}
461
462/// Shared `commentCreate`/`commentUpdate` payload shape.
463#[derive(Deserialize)]
464struct CommentPayload {
465 success: bool,
466 #[serde(default)]
467 comment: Option<Comment>,
468}
469
470impl CommentPayload {
471 fn into_comment(self, operation: &'static str) -> Result<Comment> {
472 ensure_success(operation, self.success)?;
473 self.comment.ok_or(Error::MissingData { operation })
474 }
475}