Skip to main content

linear_api/
issues.rs

1//! Issue CRUD, list/search, batch create, and label convenience operations —
2//! [`IssuesService`], obtained via [`LinearClient::issues`].
3
4use bon::Builder;
5use serde::Deserialize;
6
7use crate::client::LinearClient;
8use crate::error::{Error, Result};
9use crate::filter::IssueFilter;
10use crate::ids::{
11    CycleId, IssueId, IssueRef, LabelId, ProjectId, ProjectMilestoneId, TeamId, TemplateId, UserId,
12    WorkflowStateId,
13};
14use crate::pagination::{Page, PageInfo};
15use crate::types::{
16    IssueRelationType, IssueStub, LabelRef, MilestoneRef, PaginationOrderBy, Priority, ProjectRef,
17    StateRef, TeamRef, TimelessDate, Undefinable, UserRef,
18};
19
20/// A Linear issue with its commonly needed references embedded (state, team,
21/// people, labels, project placement, parent, and relation summaries).
22#[derive(Debug, Clone, Deserialize)]
23#[serde(rename_all = "camelCase")]
24#[non_exhaustive]
25pub struct Issue {
26    /// Issue ID (UUID).
27    pub id: IssueId,
28    /// Human identifier, e.g. `"ENG-123"`.
29    pub identifier: String,
30    /// Issue number within its team (a `Float` on the wire).
31    pub number: f64,
32    /// Issue title.
33    pub title: String,
34    /// Issue description in markdown, when set.
35    pub description: Option<String>,
36    /// URL of the issue in the Linear app.
37    pub url: String,
38    /// Suggested VCS branch name for this issue.
39    pub branch_name: String,
40    /// Priority (0 = none … 4 = low).
41    pub priority: Priority,
42    /// Human-readable priority label, e.g. `"High"`.
43    pub priority_label: String,
44    /// Estimate in the team's chosen scale, when set.
45    pub estimate: Option<f64>,
46    /// Due date, when set.
47    pub due_date: Option<TimelessDate>,
48    /// Board sort order.
49    pub sort_order: f64,
50    /// Current workflow state.
51    pub state: StateRef,
52    /// Owning team.
53    pub team: TeamRef,
54    /// Assignee, when set.
55    pub assignee: Option<UserRef>,
56    /// Creator; absent for issues created by integrations.
57    pub creator: Option<UserRef>,
58    /// Labels attached to the issue (first 50).
59    #[serde(deserialize_with = "crate::types::nodes")]
60    pub labels: Vec<LabelRef>,
61    /// Project the issue belongs to, when any.
62    pub project: Option<ProjectRef>,
63    /// Project milestone the issue is slotted into, when any.
64    pub project_milestone: Option<MilestoneRef>,
65    /// Parent issue, when this is a sub-issue.
66    pub parent: Option<IssueStub>,
67    /// Outgoing relations (first 10): this issue is the source, e.g. it
68    /// *blocks* [`OutgoingRelation::related_issue`].
69    #[serde(deserialize_with = "crate::types::nodes")]
70    pub relations: Vec<OutgoingRelation>,
71    /// Incoming relations (first 10): this issue is the target, e.g. it is
72    /// *blocked by* [`IncomingRelation::issue`].
73    #[serde(deserialize_with = "crate::types::nodes")]
74    pub inverse_relations: Vec<IncomingRelation>,
75    /// When the issue was created.
76    #[serde(with = "time::serde::rfc3339")]
77    pub created_at: time::OffsetDateTime,
78    /// When the issue was last updated.
79    #[serde(with = "time::serde::rfc3339")]
80    pub updated_at: time::OffsetDateTime,
81    /// When the issue was completed, when it was.
82    #[serde(with = "time::serde::rfc3339::option", default)]
83    pub completed_at: Option<time::OffsetDateTime>,
84    /// When the issue was canceled, when it was.
85    #[serde(with = "time::serde::rfc3339::option", default)]
86    pub canceled_at: Option<time::OffsetDateTime>,
87    /// When work on the issue started, when it did.
88    #[serde(with = "time::serde::rfc3339::option", default)]
89    pub started_at: Option<time::OffsetDateTime>,
90    /// When the issue was archived, when it was.
91    #[serde(with = "time::serde::rfc3339::option", default)]
92    pub archived_at: Option<time::OffsetDateTime>,
93}
94
95impl Issue {
96    /// Issues **this issue blocks**: outgoing relations of type
97    /// [`IssueRelationType::Blocks`], yielding their
98    /// [`related_issue`](OutgoingRelation::related_issue).
99    ///
100    /// Relation semantics: a `blocks` relation means `issueId` blocks
101    /// `relatedIssueId`; `relations` holds the outgoing side and
102    /// `inverseRelations` the incoming side.
103    pub fn blocks(&self) -> Vec<&IssueStub> {
104        self.relations
105            .iter()
106            .filter(|relation| relation.relation_type == IssueRelationType::Blocks)
107            .map(|relation| &relation.related_issue)
108            .collect()
109    }
110
111    /// Issues **this issue is blocked by**: incoming relations of type
112    /// [`IssueRelationType::Blocks`], yielding their
113    /// [`issue`](IncomingRelation::issue) (the blocker).
114    pub fn blocked_by(&self) -> Vec<&IssueStub> {
115        self.inverse_relations
116            .iter()
117            .filter(|relation| relation.relation_type == IssueRelationType::Blocks)
118            .map(|relation| &relation.issue)
119            .collect()
120    }
121}
122
123/// An outgoing issue relation: the owning issue is the source (e.g. it
124/// *blocks* [`related_issue`](Self::related_issue)).
125#[derive(Debug, Clone, Deserialize)]
126#[serde(rename_all = "camelCase")]
127#[non_exhaustive]
128pub struct OutgoingRelation {
129    /// The relation type.
130    #[serde(rename = "type")]
131    pub relation_type: IssueRelationType,
132    /// The target of the relation.
133    pub related_issue: IssueStub,
134}
135
136/// An incoming issue relation: the owning issue is the target (e.g. it is
137/// *blocked by* [`issue`](Self::issue)).
138#[derive(Debug, Clone, Deserialize)]
139#[serde(rename_all = "camelCase")]
140#[non_exhaustive]
141pub struct IncomingRelation {
142    /// The relation type.
143    #[serde(rename = "type")]
144    pub relation_type: IssueRelationType,
145    /// The source of the relation.
146    pub issue: IssueStub,
147}
148
149/// One hit from [`IssuesService::search`]. This is Linear's own
150/// `IssueSearchResult` GraphQL type (not `Issue`), so it carries a smaller
151/// field set plus search [`metadata`](Self::metadata).
152#[derive(Debug, Clone, Deserialize)]
153#[serde(rename_all = "camelCase")]
154#[non_exhaustive]
155pub struct IssueSearchResult {
156    /// Issue ID (UUID).
157    pub id: IssueId,
158    /// Human identifier, e.g. `"ENG-123"`.
159    pub identifier: String,
160    /// Issue title.
161    pub title: String,
162    /// Issue description in markdown, when set.
163    pub description: Option<String>,
164    /// URL of the issue in the Linear app.
165    pub url: String,
166    /// Priority (0 = none … 4 = low).
167    pub priority: Priority,
168    /// Estimate in the team's chosen scale, when set.
169    pub estimate: Option<f64>,
170    /// Current workflow state.
171    pub state: StateRef,
172    /// Owning team.
173    pub team: TeamRef,
174    /// Assignee, when set.
175    pub assignee: Option<UserRef>,
176    /// Labels attached to the issue (first 50).
177    #[serde(deserialize_with = "crate::types::nodes")]
178    pub labels: Vec<LabelRef>,
179    /// Search-engine metadata about the match (relevance data; shape is not
180    /// part of Linear's stable API).
181    pub metadata: serde_json::Value,
182    /// When the issue was created.
183    #[serde(with = "time::serde::rfc3339")]
184    pub created_at: time::OffsetDateTime,
185    /// When the issue was last updated.
186    #[serde(with = "time::serde::rfc3339")]
187    pub updated_at: time::OffsetDateTime,
188}
189
190/// Request for [`IssuesService::list`]. All fields are optional; the server
191/// page size defaults to 50.
192///
193/// ```
194/// use linear_api::IssueFilter;
195/// use linear_api::issues::ListIssuesRequest;
196///
197/// let request = ListIssuesRequest::builder()
198///     .filter(IssueFilter::default())
199///     .first(25)
200///     .build();
201/// ```
202#[derive(Debug, Clone, Default, serde::Serialize, Builder)]
203#[serde(rename_all = "camelCase")]
204#[non_exhaustive]
205pub struct ListIssuesRequest {
206    /// Filter the returned issues.
207    #[serde(skip_serializing_if = "Option::is_none")]
208    pub filter: Option<IssueFilter>,
209    /// Page size (server default 50). Larger pages multiply query complexity.
210    #[serde(skip_serializing_if = "Option::is_none")]
211    pub first: Option<i32>,
212    /// Cursor to continue from (a previous page's `end_cursor`).
213    #[serde(skip_serializing_if = "Option::is_none")]
214    #[builder(into)]
215    pub after: Option<String>,
216    /// Include archived issues (server default `false`).
217    #[serde(skip_serializing_if = "Option::is_none")]
218    pub include_archived: Option<bool>,
219    /// Pagination order (server default: created-at).
220    #[serde(skip_serializing_if = "Option::is_none")]
221    pub order_by: Option<PaginationOrderBy>,
222}
223
224/// Request for [`IssuesService::search`]. Only `term` is required.
225///
226/// ```
227/// use linear_api::issues::SearchIssuesRequest;
228///
229/// let request = SearchIssuesRequest::builder()
230///     .term("flux capacitor")
231///     .first(10)
232///     .build();
233/// ```
234#[derive(Debug, Clone, serde::Serialize, Builder)]
235#[serde(rename_all = "camelCase")]
236#[non_exhaustive]
237pub struct SearchIssuesRequest {
238    /// Full-text search term (required).
239    #[builder(into)]
240    pub term: String,
241    /// Filter the searched issues.
242    #[serde(skip_serializing_if = "Option::is_none")]
243    pub filter: Option<IssueFilter>,
244    /// Page size (server default 50).
245    #[serde(skip_serializing_if = "Option::is_none")]
246    pub first: Option<i32>,
247    /// Cursor to continue from (a previous page's `end_cursor`).
248    #[serde(skip_serializing_if = "Option::is_none")]
249    #[builder(into)]
250    pub after: Option<String>,
251}
252
253/// Input for [`IssuesService::create`] and [`IssuesService::batch_create`].
254/// `team_id` and `title` are required; unset optional fields are omitted from
255/// the request.
256///
257/// ```
258/// use linear_api::issues::IssueCreateInput;
259/// use linear_api::{Priority, TeamId};
260///
261/// let input = IssueCreateInput::builder()
262///     .team_id(TeamId::new("team-1"))
263///     .title("Fix the flux capacitor")
264///     .priority(Priority::High)
265///     .build();
266/// ```
267#[derive(Debug, Clone, serde::Serialize, Builder)]
268#[serde(rename_all = "camelCase")]
269#[non_exhaustive]
270pub struct IssueCreateInput {
271    /// Team to create the issue in (required).
272    #[builder(into)]
273    pub team_id: TeamId,
274    /// Issue title (required).
275    #[builder(into)]
276    pub title: String,
277    /// Description in markdown.
278    #[serde(skip_serializing_if = "Option::is_none")]
279    #[builder(into)]
280    pub description: Option<String>,
281    /// Assignee.
282    #[serde(skip_serializing_if = "Option::is_none")]
283    pub assignee_id: Option<UserId>,
284    /// Workflow state (defaults to the team's first state).
285    #[serde(skip_serializing_if = "Option::is_none")]
286    pub state_id: Option<WorkflowStateId>,
287    /// Priority.
288    #[serde(skip_serializing_if = "Option::is_none")]
289    pub priority: Option<Priority>,
290    /// Estimate in the team's chosen scale.
291    #[serde(skip_serializing_if = "Option::is_none")]
292    pub estimate: Option<i64>,
293    /// Labels to attach.
294    #[serde(skip_serializing_if = "Option::is_none")]
295    pub label_ids: Option<Vec<LabelId>>,
296    /// Project to place the issue in.
297    #[serde(skip_serializing_if = "Option::is_none")]
298    pub project_id: Option<ProjectId>,
299    /// Project milestone to slot the issue into.
300    #[serde(skip_serializing_if = "Option::is_none")]
301    pub project_milestone_id: Option<ProjectMilestoneId>,
302    /// Cycle to schedule the issue into.
303    #[serde(skip_serializing_if = "Option::is_none")]
304    pub cycle_id: Option<CycleId>,
305    /// Parent issue (makes this a sub-issue).
306    #[serde(skip_serializing_if = "Option::is_none")]
307    pub parent_id: Option<IssueId>,
308    /// Due date.
309    #[serde(skip_serializing_if = "Option::is_none")]
310    pub due_date: Option<TimelessDate>,
311    /// Board sort order.
312    #[serde(skip_serializing_if = "Option::is_none")]
313    pub sort_order: Option<f64>,
314    /// Users to subscribe to the issue.
315    #[serde(skip_serializing_if = "Option::is_none")]
316    pub subscriber_ids: Option<Vec<UserId>>,
317    /// Template to apply.
318    #[serde(skip_serializing_if = "Option::is_none")]
319    pub template_id: Option<TemplateId>,
320    /// Display name to create the issue as (app credentials only).
321    #[serde(skip_serializing_if = "Option::is_none")]
322    #[builder(into)]
323    pub create_as_user: Option<String>,
324}
325
326/// Input for [`IssuesService::update`]. Every field is optional.
327///
328/// Plain `Option` fields are *set-only*; [`Undefinable`] fields are
329/// tri-state: leave them [`Undefinable::Undefined`] (the default) to keep the
330/// current value, set [`Undefinable::Null`] to **clear** it, or a value to
331/// change it.
332///
333/// For labels, prefer [`added_label_ids`](Self::added_label_ids) /
334/// [`removed_label_ids`](Self::removed_label_ids) over replacing the whole
335/// set with [`label_ids`](Self::label_ids) — the delta form cannot clobber
336/// labels added concurrently by someone else.
337///
338/// ```
339/// use linear_api::Undefinable;
340/// use linear_api::issues::IssueUpdateInput;
341///
342/// // Set the estimate, clear the assignee, leave everything else unchanged.
343/// let input = IssueUpdateInput::builder()
344///     .estimate(3)
345///     .assignee_id(Undefinable::Null)
346///     .build();
347/// ```
348#[derive(Debug, Clone, Default, serde::Serialize, Builder)]
349#[serde(rename_all = "camelCase")]
350#[non_exhaustive]
351pub struct IssueUpdateInput {
352    /// New title.
353    #[serde(skip_serializing_if = "Option::is_none")]
354    #[builder(into)]
355    pub title: Option<String>,
356    /// New workflow state.
357    #[serde(skip_serializing_if = "Option::is_none")]
358    pub state_id: Option<WorkflowStateId>,
359    /// New priority.
360    #[serde(skip_serializing_if = "Option::is_none")]
361    pub priority: Option<Priority>,
362    /// New board sort order.
363    #[serde(skip_serializing_if = "Option::is_none")]
364    pub sort_order: Option<f64>,
365    /// Replace the full label set. Prefer
366    /// [`added_label_ids`](Self::added_label_ids) /
367    /// [`removed_label_ids`](Self::removed_label_ids).
368    #[serde(skip_serializing_if = "Option::is_none")]
369    pub label_ids: Option<Vec<LabelId>>,
370    /// Labels to add, keeping existing ones.
371    #[serde(skip_serializing_if = "Option::is_none")]
372    pub added_label_ids: Option<Vec<LabelId>>,
373    /// Labels to remove, keeping the rest.
374    #[serde(skip_serializing_if = "Option::is_none")]
375    pub removed_label_ids: Option<Vec<LabelId>>,
376    /// Description in markdown.
377    ///
378    /// Live-verified server quirk: Linear **ignores `null`** for this
379    /// document-backed field — [`Undefinable::Null`] is a no-op here. To
380    /// clear the description, set it to an empty string.
381    #[serde(skip_serializing_if = "Undefinable::is_undefined", default)]
382    #[builder(default, into)]
383    pub description: Undefinable<String>,
384    /// Assignee ([`Undefinable::Null`] unassigns).
385    #[serde(skip_serializing_if = "Undefinable::is_undefined", default)]
386    #[builder(default, into)]
387    pub assignee_id: Undefinable<UserId>,
388    /// Estimate ([`Undefinable::Null`] clears it).
389    #[serde(skip_serializing_if = "Undefinable::is_undefined", default)]
390    #[builder(default, into)]
391    pub estimate: Undefinable<i64>,
392    /// Project ([`Undefinable::Null`] removes the issue from its project).
393    #[serde(skip_serializing_if = "Undefinable::is_undefined", default)]
394    #[builder(default, into)]
395    pub project_id: Undefinable<ProjectId>,
396    /// Project milestone ([`Undefinable::Null`] clears it).
397    #[serde(skip_serializing_if = "Undefinable::is_undefined", default)]
398    #[builder(default, into)]
399    pub project_milestone_id: Undefinable<ProjectMilestoneId>,
400    /// Cycle ([`Undefinable::Null`] removes the issue from its cycle).
401    #[serde(skip_serializing_if = "Undefinable::is_undefined", default)]
402    #[builder(default, into)]
403    pub cycle_id: Undefinable<CycleId>,
404    /// Parent issue ([`Undefinable::Null`] promotes it to a top-level issue).
405    #[serde(skip_serializing_if = "Undefinable::is_undefined", default)]
406    #[builder(default, into)]
407    pub parent_id: Undefinable<IssueId>,
408    /// Due date ([`Undefinable::Null`] clears it).
409    #[serde(skip_serializing_if = "Undefinable::is_undefined", default)]
410    #[builder(default, into)]
411    pub due_date: Undefinable<TimelessDate>,
412}
413
414/// Shared tail for every document that spreads `...IssueFields`. A macro (not
415/// a `const`) so it can be `concat!`-ed into `&'static str` documents.
416macro_rules! issue_fields_fragments {
417    () => {
418        " fragment IssueFields on Issue { id identifier number title description url branchName \
419         priority priorityLabel estimate dueDate sortOrder createdAt updatedAt completedAt \
420         canceledAt startedAt archivedAt state { ...StateRefFields } team { ...TeamRefFields } \
421         assignee { ...UserRefFields } creator { ...UserRefFields } \
422         labels(first: 50) { nodes { ...LabelRefFields } } project { ...ProjectRefFields } \
423         projectMilestone { ...MilestoneRefFields } parent { ...IssueStubFields } \
424         relations(first: 10) { nodes { type relatedIssue { ...IssueStubFields } } } \
425         inverseRelations(first: 10) { nodes { type issue { ...IssueStubFields } } } } \
426         fragment UserRefFields on User { id name displayName } \
427         fragment TeamRefFields on Team { id key name } \
428         fragment ProjectRefFields on Project { id name } \
429         fragment IssueStubFields on Issue { id identifier title } \
430         fragment LabelRefFields on IssueLabel { id name color } \
431         fragment StateRefFields on WorkflowState { id name type color } \
432         fragment MilestoneRefFields on ProjectMilestone { id name }"
433    };
434}
435
436const ISSUE_GET: &str = concat!(
437    "query IssueGet($id: String!) { issue(id: $id) { ...IssueFields } }",
438    issue_fields_fragments!()
439);
440
441const ISSUE_LIST: &str = concat!(
442    "query IssueList($filter: IssueFilter, $first: Int, $after: String, \
443     $includeArchived: Boolean, $orderBy: PaginationOrderBy) { \
444     issues(filter: $filter, first: $first, after: $after, \
445     includeArchived: $includeArchived, orderBy: $orderBy) { \
446     nodes { ...IssueFields } \
447     pageInfo { hasNextPage hasPreviousPage startCursor endCursor } } }",
448    issue_fields_fragments!()
449);
450
451// `IssueSearchResult` is its own GraphQL type — a fragment on `Issue` cannot
452// spread into it, so its selection is written inline.
453const ISSUE_SEARCH: &str = "query IssueSearch($term: String!, $filter: IssueFilter, $first: Int, $after: String) { \
454     searchIssues(term: $term, filter: $filter, first: $first, after: $after) { \
455     nodes { id identifier title description url priority estimate metadata createdAt updatedAt \
456     state { id name type color } team { id key name } assignee { id name displayName } \
457     labels(first: 50) { nodes { id name color } } } \
458     pageInfo { hasNextPage hasPreviousPage startCursor endCursor } } }";
459
460const ISSUE_CREATE: &str = concat!(
461    "mutation IssueCreate($input: IssueCreateInput!) { \
462     issueCreate(input: $input) { success issue { ...IssueFields } } }",
463    issue_fields_fragments!()
464);
465
466const ISSUE_BATCH_CREATE: &str = concat!(
467    "mutation IssueBatchCreate($input: IssueBatchCreateInput!) { \
468     issueBatchCreate(input: $input) { success issues { ...IssueFields } } }",
469    issue_fields_fragments!()
470);
471
472const ISSUE_UPDATE: &str = concat!(
473    "mutation IssueUpdate($id: String!, $input: IssueUpdateInput!) { \
474     issueUpdate(id: $id, input: $input) { success issue { ...IssueFields } } }",
475    issue_fields_fragments!()
476);
477
478const ISSUE_ARCHIVE: &str =
479    "mutation IssueArchive($id: String!) { issueArchive(id: $id) { success } }";
480
481const ISSUE_DELETE: &str =
482    "mutation IssueDelete($id: String!) { issueDelete(id: $id) { success } }";
483
484const ISSUE_ADD_LABEL: &str = concat!(
485    "mutation IssueAddLabel($id: String!, $labelId: String!) { \
486     issueAddLabel(id: $id, labelId: $labelId) { success issue { ...IssueFields } } }",
487    issue_fields_fragments!()
488);
489
490const ISSUE_REMOVE_LABEL: &str = concat!(
491    "mutation IssueRemoveLabel($id: String!, $labelId: String!) { \
492     issueRemoveLabel(id: $id, labelId: $labelId) { success issue { ...IssueFields } } }",
493    issue_fields_fragments!()
494);
495
496pub(crate) const DOCUMENTS: &[(&str, &str)] = &[
497    ("IssueGet", ISSUE_GET),
498    ("IssueList", ISSUE_LIST),
499    ("IssueSearch", ISSUE_SEARCH),
500    ("IssueCreate", ISSUE_CREATE),
501    ("IssueBatchCreate", ISSUE_BATCH_CREATE),
502    ("IssueUpdate", ISSUE_UPDATE),
503    ("IssueArchive", ISSUE_ARCHIVE),
504    ("IssueDelete", ISSUE_DELETE),
505    ("IssueAddLabel", ISSUE_ADD_LABEL),
506    ("IssueRemoveLabel", ISSUE_REMOVE_LABEL),
507];
508
509/// `issueCreate` / `issueUpdate` / `issueAddLabel` / `issueRemoveLabel`
510/// payload.
511#[derive(Deserialize)]
512struct IssuePayload {
513    success: bool,
514    issue: Option<Issue>,
515}
516
517fn payload_issue(operation: &'static str, payload: IssuePayload) -> Result<Issue> {
518    crate::types::ensure_success(operation, payload.success)?;
519    payload.issue.ok_or(Error::MissingData { operation })
520}
521
522/// `issueArchive` / `issueDelete` payload (only `success` is selected).
523#[derive(Deserialize)]
524struct SuccessPayload {
525    success: bool,
526}
527
528/// Issue operations. Obtained via [`LinearClient::issues`]; `Copy`, so it can
529/// be freely captured by pagination closures.
530#[derive(Clone, Copy)]
531pub struct IssuesService<'a> {
532    client: &'a LinearClient,
533}
534
535impl LinearClient {
536    /// Issue operations: CRUD, list/search, batch create, and label
537    /// convenience mutations.
538    ///
539    /// ```no_run
540    /// # async fn example() -> linear_api::Result<()> {
541    /// let client = linear_api::LinearClient::from_env()?;
542    /// let issue = client
543    ///     .issues()
544    ///     .get(linear_api::IssueRef::identifier("ENG-123"))
545    ///     .await?;
546    /// println!("{}: {}", issue.identifier, issue.title);
547    /// # Ok(()) }
548    /// ```
549    pub fn issues(&self) -> IssuesService<'_> {
550        IssuesService { client: self }
551    }
552}
553
554impl<'a> IssuesService<'a> {
555    /// Fetches one issue by UUID or human identifier.
556    ///
557    /// ```no_run
558    /// # async fn example() -> linear_api::Result<()> {
559    /// let client = linear_api::LinearClient::from_env()?;
560    /// let issue = client
561    ///     .issues()
562    ///     .get(linear_api::IssueRef::identifier("ENG-123"))
563    ///     .await?;
564    /// assert_eq!(issue.identifier, "ENG-123");
565    /// # Ok(()) }
566    /// ```
567    pub async fn get(&self, issue: impl Into<IssueRef>) -> Result<Issue> {
568        #[derive(Deserialize)]
569        struct Data {
570            issue: Issue,
571        }
572        let issue = issue.into();
573        let data: Data = self
574            .client
575            .query(
576                "IssueGet",
577                ISSUE_GET,
578                serde_json::json!({ "id": issue.api_string() }),
579            )
580            .await?;
581        Ok(data.issue)
582    }
583
584    /// Fetches one page of issues.
585    ///
586    /// ```no_run
587    /// # async fn example() -> linear_api::Result<()> {
588    /// use linear_api::issues::ListIssuesRequest;
589    ///
590    /// let client = linear_api::LinearClient::from_env()?;
591    /// let page = client
592    ///     .issues()
593    ///     .list(ListIssuesRequest::builder().first(25).build())
594    ///     .await?;
595    /// println!("{} issues, more: {}", page.nodes.len(), page.page_info.has_next_page);
596    /// # Ok(()) }
597    /// ```
598    pub async fn list(&self, req: ListIssuesRequest) -> Result<Page<Issue>> {
599        #[derive(Deserialize)]
600        #[serde(rename_all = "camelCase")]
601        struct Connection {
602            nodes: Vec<Issue>,
603            page_info: PageInfo,
604        }
605        #[derive(Deserialize)]
606        struct Data {
607            issues: Connection,
608        }
609        let data: Data = self.client.query("IssueList", ISSUE_LIST, req).await?;
610        Ok(Page {
611            nodes: data.issues.nodes,
612            page_info: data.issues.page_info,
613        })
614    }
615
616    /// Lazily streams issues across pages, starting from `req.after` when
617    /// set (the cursor then advances page by page).
618    ///
619    /// ```no_run
620    /// # async fn example() -> linear_api::Result<()> {
621    /// use futures::TryStreamExt;
622    /// use linear_api::issues::ListIssuesRequest;
623    ///
624    /// let client = linear_api::LinearClient::from_env()?;
625    /// let issues = client.issues();
626    /// let mut stream = std::pin::pin!(
627    ///     issues.list_stream(ListIssuesRequest::builder().first(50).build())
628    /// );
629    /// while let Some(issue) = stream.try_next().await? {
630    ///     println!("{}: {}", issue.identifier, issue.title);
631    /// }
632    /// # Ok(()) }
633    /// ```
634    pub fn list_stream(
635        &self,
636        req: ListIssuesRequest,
637    ) -> impl futures::Stream<Item = Result<Issue>> + 'a {
638        let service = *self;
639        crate::pagination::paginate(move |cursor| {
640            let mut req = req.clone();
641            // The first call keeps a caller-seeded `req.after`; later calls
642            // advance to each page's end cursor.
643            if cursor.is_some() {
644                req.after = cursor;
645            }
646            async move { service.list(req).await }
647        })
648    }
649
650    /// Full-text search over issues via the `searchIssues` API (rate-limited
651    /// by Linear to 30 requests per minute).
652    ///
653    /// ```no_run
654    /// # async fn example() -> linear_api::Result<()> {
655    /// use linear_api::issues::SearchIssuesRequest;
656    ///
657    /// let client = linear_api::LinearClient::from_env()?;
658    /// let hits = client
659    ///     .issues()
660    ///     .search(SearchIssuesRequest::builder().term("flux capacitor").build())
661    ///     .await?;
662    /// for hit in &hits.nodes {
663    ///     println!("{}: {}", hit.identifier, hit.title);
664    /// }
665    /// # Ok(()) }
666    /// ```
667    pub async fn search(&self, req: SearchIssuesRequest) -> Result<Page<IssueSearchResult>> {
668        #[derive(Deserialize)]
669        #[serde(rename_all = "camelCase")]
670        struct Connection {
671            nodes: Vec<IssueSearchResult>,
672            page_info: PageInfo,
673        }
674        #[derive(Deserialize)]
675        #[serde(rename_all = "camelCase")]
676        struct Data {
677            search_issues: Connection,
678        }
679        let data: Data = self.client.query("IssueSearch", ISSUE_SEARCH, req).await?;
680        Ok(Page {
681            nodes: data.search_issues.nodes,
682            page_info: data.search_issues.page_info,
683        })
684    }
685
686    /// Creates one issue.
687    ///
688    /// ```no_run
689    /// # async fn example() -> linear_api::Result<()> {
690    /// use linear_api::TeamId;
691    /// use linear_api::issues::IssueCreateInput;
692    ///
693    /// let client = linear_api::LinearClient::from_env()?;
694    /// let issue = client
695    ///     .issues()
696    ///     .create(
697    ///         IssueCreateInput::builder()
698    ///             .team_id(TeamId::new("9cfb482a-81e3-4154-b5b9-2c805e70a02d"))
699    ///             .title("Fix the flux capacitor")
700    ///             .build(),
701    ///     )
702    ///     .await?;
703    /// println!("created {}", issue.identifier);
704    /// # Ok(()) }
705    /// ```
706    pub async fn create(&self, input: IssueCreateInput) -> Result<Issue> {
707        #[derive(Deserialize)]
708        #[serde(rename_all = "camelCase")]
709        struct Data {
710            issue_create: IssuePayload,
711        }
712        let data: Data = self
713            .client
714            .mutation(
715                "IssueCreate",
716                ISSUE_CREATE,
717                serde_json::json!({ "input": input }),
718            )
719            .await?;
720        payload_issue("IssueCreate", data.issue_create)
721    }
722
723    /// Creates several issues in one transaction (`issueBatchCreate`).
724    ///
725    /// ```no_run
726    /// # async fn example() -> linear_api::Result<()> {
727    /// use linear_api::TeamId;
728    /// use linear_api::issues::IssueCreateInput;
729    ///
730    /// let client = linear_api::LinearClient::from_env()?;
731    /// let team = TeamId::new("9cfb482a-81e3-4154-b5b9-2c805e70a02d");
732    /// let issues = client
733    ///     .issues()
734    ///     .batch_create(vec![
735    ///         IssueCreateInput::builder().team_id(team.clone()).title("One").build(),
736    ///         IssueCreateInput::builder().team_id(team).title("Two").build(),
737    ///     ])
738    ///     .await?;
739    /// assert_eq!(issues.len(), 2);
740    /// # Ok(()) }
741    /// ```
742    pub async fn batch_create(&self, issues: Vec<IssueCreateInput>) -> Result<Vec<Issue>> {
743        #[derive(Deserialize)]
744        struct Payload {
745            success: bool,
746            issues: Vec<Issue>,
747        }
748        #[derive(Deserialize)]
749        #[serde(rename_all = "camelCase")]
750        struct Data {
751            issue_batch_create: Payload,
752        }
753        let data: Data = self
754            .client
755            .mutation(
756                "IssueBatchCreate",
757                ISSUE_BATCH_CREATE,
758                serde_json::json!({ "input": { "issues": issues } }),
759            )
760            .await?;
761        crate::types::ensure_success("IssueBatchCreate", data.issue_batch_create.success)?;
762        Ok(data.issue_batch_create.issues)
763    }
764
765    /// Updates one issue by UUID or human identifier. See
766    /// [`IssueUpdateInput`] for set/clear/leave-unchanged semantics.
767    ///
768    /// ```no_run
769    /// # async fn example() -> linear_api::Result<()> {
770    /// use linear_api::issues::IssueUpdateInput;
771    /// use linear_api::{IssueRef, Undefinable};
772    ///
773    /// let client = linear_api::LinearClient::from_env()?;
774    /// let issue = client
775    ///     .issues()
776    ///     .update(
777    ///         IssueRef::identifier("ENG-123"),
778    ///         IssueUpdateInput::builder()
779    ///             .title("Fix the flux capacitor for real")
780    ///             .due_date(Undefinable::Null) // clear the due date
781    ///             .build(),
782    ///     )
783    ///     .await?;
784    /// println!("updated {}", issue.identifier);
785    /// # Ok(()) }
786    /// ```
787    pub async fn update(
788        &self,
789        issue: impl Into<IssueRef>,
790        input: IssueUpdateInput,
791    ) -> Result<Issue> {
792        #[derive(Deserialize)]
793        #[serde(rename_all = "camelCase")]
794        struct Data {
795            issue_update: IssuePayload,
796        }
797        let issue = issue.into();
798        let data: Data = self
799            .client
800            .mutation(
801                "IssueUpdate",
802                ISSUE_UPDATE,
803                serde_json::json!({ "id": issue.api_string(), "input": input }),
804            )
805            .await?;
806        payload_issue("IssueUpdate", data.issue_update)
807    }
808
809    /// Archives one issue.
810    ///
811    /// ```no_run
812    /// # async fn example() -> linear_api::Result<()> {
813    /// let client = linear_api::LinearClient::from_env()?;
814    /// client
815    ///     .issues()
816    ///     .archive(linear_api::IssueRef::identifier("ENG-123"))
817    ///     .await?;
818    /// # Ok(()) }
819    /// ```
820    pub async fn archive(&self, issue: impl Into<IssueRef>) -> Result<()> {
821        #[derive(Deserialize)]
822        #[serde(rename_all = "camelCase")]
823        struct Data {
824            issue_archive: SuccessPayload,
825        }
826        let issue = issue.into();
827        let data: Data = self
828            .client
829            .mutation(
830                "IssueArchive",
831                ISSUE_ARCHIVE,
832                serde_json::json!({ "id": issue.api_string() }),
833            )
834            .await?;
835        crate::types::ensure_success("IssueArchive", data.issue_archive.success)
836    }
837
838    /// Deletes (trashes) one issue. Linear keeps trashed issues recoverable
839    /// for a grace period.
840    ///
841    /// ```no_run
842    /// # async fn example() -> linear_api::Result<()> {
843    /// let client = linear_api::LinearClient::from_env()?;
844    /// client
845    ///     .issues()
846    ///     .delete(linear_api::IssueRef::identifier("ENG-123"))
847    ///     .await?;
848    /// # Ok(()) }
849    /// ```
850    pub async fn delete(&self, issue: impl Into<IssueRef>) -> Result<()> {
851        #[derive(Deserialize)]
852        #[serde(rename_all = "camelCase")]
853        struct Data {
854            issue_delete: SuccessPayload,
855        }
856        let issue = issue.into();
857        let data: Data = self
858            .client
859            .mutation(
860                "IssueDelete",
861                ISSUE_DELETE,
862                serde_json::json!({ "id": issue.api_string() }),
863            )
864            .await?;
865        crate::types::ensure_success("IssueDelete", data.issue_delete.success)
866    }
867
868    /// Adds one label to an issue, returning the updated issue. For bulk
869    /// label changes prefer [`IssuesService::update`] with
870    /// [`IssueUpdateInput::added_label_ids`].
871    ///
872    /// ```no_run
873    /// # async fn example() -> linear_api::Result<()> {
874    /// let client = linear_api::LinearClient::from_env()?;
875    /// let label = linear_api::LabelId::new("2f7fb5b1-9d5d-4d70-a806-04f8ad4c3702");
876    /// let issue = client
877    ///     .issues()
878    ///     .add_label(linear_api::IssueRef::identifier("ENG-123"), &label)
879    ///     .await?;
880    /// println!("{} now has {} labels", issue.identifier, issue.labels.len());
881    /// # Ok(()) }
882    /// ```
883    pub async fn add_label(&self, issue: impl Into<IssueRef>, label: &LabelId) -> Result<Issue> {
884        #[derive(Deserialize)]
885        #[serde(rename_all = "camelCase")]
886        struct Data {
887            issue_add_label: IssuePayload,
888        }
889        let issue = issue.into();
890        let data: Data = self
891            .client
892            .mutation(
893                "IssueAddLabel",
894                ISSUE_ADD_LABEL,
895                serde_json::json!({ "id": issue.api_string(), "labelId": label.as_str() }),
896            )
897            .await?;
898        payload_issue("IssueAddLabel", data.issue_add_label)
899    }
900
901    /// Removes one label from an issue, returning the updated issue. For bulk
902    /// label changes prefer [`IssuesService::update`] with
903    /// [`IssueUpdateInput::removed_label_ids`].
904    ///
905    /// ```no_run
906    /// # async fn example() -> linear_api::Result<()> {
907    /// let client = linear_api::LinearClient::from_env()?;
908    /// let label = linear_api::LabelId::new("2f7fb5b1-9d5d-4d70-a806-04f8ad4c3702");
909    /// let issue = client
910    ///     .issues()
911    ///     .remove_label(linear_api::IssueRef::identifier("ENG-123"), &label)
912    ///     .await?;
913    /// println!("{} now has {} labels", issue.identifier, issue.labels.len());
914    /// # Ok(()) }
915    /// ```
916    pub async fn remove_label(&self, issue: impl Into<IssueRef>, label: &LabelId) -> Result<Issue> {
917        #[derive(Deserialize)]
918        #[serde(rename_all = "camelCase")]
919        struct Data {
920            issue_remove_label: IssuePayload,
921        }
922        let issue = issue.into();
923        let data: Data = self
924            .client
925            .mutation(
926                "IssueRemoveLabel",
927                ISSUE_REMOVE_LABEL,
928                serde_json::json!({ "id": issue.api_string(), "labelId": label.as_str() }),
929            )
930            .await?;
931        payload_issue("IssueRemoveLabel", data.issue_remove_label)
932    }
933}