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}