Skip to main content

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}