linear_api/projects.rs
1//! Projects, project statuses, and project milestones.
2//!
3//! Access through [`LinearClient::projects`]:
4//!
5//! ```no_run
6//! # async fn example() -> linear_api::Result<()> {
7//! let client = linear_api::LinearClient::from_env()?;
8//! let page = client
9//! .projects()
10//! .list(linear_api::projects::ListProjectsRequest::builder().build())
11//! .await?;
12//! for project in &page.nodes {
13//! println!("{} ({:?})", project.name, project.status.status_type);
14//! }
15//! # Ok(()) }
16//! ```
17//!
18//! Naming note: Linear has both a mutation `projectUpdate` (updates a
19//! `Project`) and an entity type `ProjectUpdate` (a status post). This module
20//! implements the former as [`ProjectsService::update`]; status posts are out
21//! of scope here.
22
23use bon::Builder;
24use serde::{Deserialize, Serialize};
25
26use crate::client::LinearClient;
27use crate::error::{Error, Result};
28use crate::filter::ProjectFilter;
29use crate::ids::{ProjectId, ProjectMilestoneId, ProjectStatusId, TeamId, UserId};
30use crate::pagination::{Page, PageInfo};
31use crate::types::{
32 PaginationOrderBy, Priority, ProjectHealth, ProjectStatusType, TeamRef, TimelessDate,
33 Undefinable, UserRef, ensure_success,
34};
35
36/// The canonical `Project` selection, together with the Ref fragments it
37/// spreads. Appended to every document that returns full projects.
38macro_rules! project_fragments {
39 () => {
40 concat!(
41 " fragment ProjectFields on Project { id name slugId url description content",
42 " status { id name type } health priority progress startDate targetDate",
43 " lead { ...UserRefFields } teams(first: 25) { nodes { ...TeamRefFields } }",
44 " createdAt updatedAt completedAt canceledAt archivedAt }",
45 " fragment UserRefFields on User { id name displayName }",
46 " fragment TeamRefFields on Team { id key name }"
47 )
48 };
49}
50
51/// The canonical `ProjectMilestone` selection.
52macro_rules! milestone_fragment {
53 () => {
54 concat!(
55 " fragment MilestoneFields on ProjectMilestone",
56 " { id name description targetDate sortOrder }"
57 )
58 };
59}
60
61const PROJECT_LIST: &str = concat!(
62 "query ProjectList($filter: ProjectFilter, $first: Int, $after: String,",
63 " $includeArchived: Boolean, $orderBy: PaginationOrderBy) {",
64 " projects(filter: $filter, first: $first, after: $after,",
65 " includeArchived: $includeArchived, orderBy: $orderBy) {",
66 " nodes { ...ProjectFields }",
67 " pageInfo { hasNextPage hasPreviousPage startCursor endCursor } } }",
68 project_fragments!()
69);
70
71const PROJECT_GET: &str = concat!(
72 "query ProjectGet($id: String!) { project(id: $id) { ...ProjectFields } }",
73 project_fragments!()
74);
75
76const PROJECT_CREATE: &str = concat!(
77 "mutation ProjectCreate($input: ProjectCreateInput!) {",
78 " projectCreate(input: $input) { success project { ...ProjectFields } } }",
79 project_fragments!()
80);
81
82const UPDATE_PROJECT: &str = concat!(
83 "mutation UpdateProject($id: String!, $input: ProjectUpdateInput!) {",
84 " projectUpdate(id: $id, input: $input) { success project { ...ProjectFields } } }",
85 project_fragments!()
86);
87
88const PROJECT_ARCHIVE: &str =
89 "mutation ProjectArchive($id: String!) { projectArchive(id: $id) { success } }";
90
91const PROJECT_STATUSES: &str =
92 "query ProjectStatuses { projectStatuses(first: 50) { nodes { id name type } } }";
93
94const PROJECT_MILESTONES: &str = concat!(
95 "query ProjectMilestones($id: String!, $first: Int, $after: String) {",
96 " project(id: $id) { projectMilestones(first: $first, after: $after) {",
97 " nodes { ...MilestoneFields }",
98 " pageInfo { hasNextPage hasPreviousPage startCursor endCursor } } } }",
99 milestone_fragment!()
100);
101
102const PROJECT_MILESTONE_CREATE: &str = concat!(
103 "mutation ProjectMilestoneCreate($input: ProjectMilestoneCreateInput!) {",
104 " projectMilestoneCreate(input: $input) {",
105 " success projectMilestone { ...MilestoneFields } } }",
106 milestone_fragment!()
107);
108
109const PROJECT_MILESTONE_UPDATE: &str = concat!(
110 "mutation ProjectMilestoneUpdate($id: String!, $input: ProjectMilestoneUpdateInput!) {",
111 " projectMilestoneUpdate(id: $id, input: $input) {",
112 " success projectMilestone { ...MilestoneFields } } }",
113 milestone_fragment!()
114);
115
116const PROJECT_MILESTONE_DELETE: &str = concat!(
117 "mutation ProjectMilestoneDelete($id: String!) {",
118 " projectMilestoneDelete(id: $id) { success } }"
119);
120
121pub(crate) const DOCUMENTS: &[(&str, &str)] = &[
122 ("ProjectList", PROJECT_LIST),
123 ("ProjectGet", PROJECT_GET),
124 ("ProjectCreate", PROJECT_CREATE),
125 ("UpdateProject", UPDATE_PROJECT),
126 ("ProjectArchive", PROJECT_ARCHIVE),
127 ("ProjectStatuses", PROJECT_STATUSES),
128 ("ProjectMilestones", PROJECT_MILESTONES),
129 ("ProjectMilestoneCreate", PROJECT_MILESTONE_CREATE),
130 ("ProjectMilestoneUpdate", PROJECT_MILESTONE_UPDATE),
131 ("ProjectMilestoneDelete", PROJECT_MILESTONE_DELETE),
132];
133
134/// A Linear project.
135#[derive(Debug, Clone, Deserialize)]
136#[serde(rename_all = "camelCase")]
137#[non_exhaustive]
138pub struct Project {
139 /// Project ID.
140 pub id: ProjectId,
141 /// Project name.
142 pub name: String,
143 /// URL slug identifier, e.g. `"sdk-v1-8f2a1c0d3b4e"`.
144 pub slug_id: String,
145 /// Canonical URL of the project in the Linear app.
146 pub url: String,
147 /// Short description (empty string when unset).
148 pub description: String,
149 /// Long-form markdown content, when set.
150 pub content: Option<String>,
151 /// Current project status.
152 pub status: ProjectStatus,
153 /// Health as of the latest project update, when reported.
154 pub health: Option<ProjectHealth>,
155 /// Project priority.
156 pub priority: Priority,
157 /// Completion progress in `0.0..=1.0`.
158 pub progress: f64,
159 /// Planned start date.
160 pub start_date: Option<TimelessDate>,
161 /// Planned target date.
162 pub target_date: Option<TimelessDate>,
163 /// Project lead, when assigned.
164 pub lead: Option<UserRef>,
165 /// Teams the project belongs to (first 25).
166 #[serde(deserialize_with = "crate::types::nodes")]
167 pub teams: Vec<TeamRef>,
168 /// When the project was created.
169 #[serde(with = "time::serde::rfc3339")]
170 pub created_at: time::OffsetDateTime,
171 /// When the project was last updated.
172 #[serde(with = "time::serde::rfc3339")]
173 pub updated_at: time::OffsetDateTime,
174 /// When the project was completed, if it was.
175 #[serde(with = "time::serde::rfc3339::option", default)]
176 pub completed_at: Option<time::OffsetDateTime>,
177 /// When the project was canceled, if it was.
178 #[serde(with = "time::serde::rfc3339::option", default)]
179 pub canceled_at: Option<time::OffsetDateTime>,
180 /// When the project was archived, if it was.
181 #[serde(with = "time::serde::rfc3339::option", default)]
182 pub archived_at: Option<time::OffsetDateTime>,
183}
184
185/// A workspace-level project status (the column a project sits in).
186#[derive(Debug, Clone, Deserialize)]
187#[serde(rename_all = "camelCase")]
188#[non_exhaustive]
189pub struct ProjectStatus {
190 /// Project status ID.
191 pub id: ProjectStatusId,
192 /// Status name, e.g. `"In Progress"`.
193 pub name: String,
194 /// Status category.
195 #[serde(rename = "type")]
196 pub status_type: ProjectStatusType,
197}
198
199/// A milestone within a project.
200#[derive(Debug, Clone, Deserialize)]
201#[serde(rename_all = "camelCase")]
202#[non_exhaustive]
203pub struct ProjectMilestone {
204 /// Milestone ID.
205 pub id: ProjectMilestoneId,
206 /// Milestone name.
207 pub name: String,
208 /// Milestone description, when set.
209 pub description: Option<String>,
210 /// Target date, when set.
211 pub target_date: Option<TimelessDate>,
212 /// Sort order within the project.
213 pub sort_order: f64,
214}
215
216/// Request parameters for [`ProjectsService::list`] /
217/// [`ProjectsService::list_stream`]. Serialized directly as the operation's
218/// GraphQL variables.
219///
220/// ```
221/// use linear_api::ProjectFilter;
222/// use linear_api::projects::ListProjectsRequest;
223///
224/// let request = ListProjectsRequest::builder()
225/// .filter(ProjectFilter::builder().build())
226/// .first(25)
227/// .build();
228/// assert_eq!(request.first, Some(25));
229/// ```
230#[derive(Debug, Clone, Default, Serialize, Builder)]
231#[serde(rename_all = "camelCase")]
232#[non_exhaustive]
233pub struct ListProjectsRequest {
234 /// Filter to apply.
235 #[serde(skip_serializing_if = "Option::is_none")]
236 pub filter: Option<ProjectFilter>,
237 /// Page size (server default 50).
238 #[serde(skip_serializing_if = "Option::is_none")]
239 pub first: Option<i32>,
240 /// Cursor to resume after (from
241 /// [`PageInfo::end_cursor`](crate::PageInfo)).
242 #[serde(skip_serializing_if = "Option::is_none")]
243 #[builder(into)]
244 pub after: Option<String>,
245 /// Whether to include archived projects (server default `false`).
246 #[serde(skip_serializing_if = "Option::is_none")]
247 pub include_archived: Option<bool>,
248 /// Pagination sort order (server default `createdAt`).
249 #[serde(skip_serializing_if = "Option::is_none")]
250 pub order_by: Option<PaginationOrderBy>,
251}
252
253/// Input for [`ProjectsService::create`].
254///
255/// ```
256/// use linear_api::TeamId;
257/// use linear_api::projects::ProjectCreateInput;
258///
259/// let input = ProjectCreateInput::builder()
260/// .name("SDK v1".to_owned())
261/// .team_ids(vec![TeamId::new("team-1")])
262/// .target_date("2026-09-01".parse().unwrap())
263/// .build();
264/// assert_eq!(input.name, "SDK v1");
265/// ```
266#[derive(Debug, Clone, Serialize, Builder)]
267#[serde(rename_all = "camelCase")]
268#[non_exhaustive]
269pub struct ProjectCreateInput {
270 /// Project name (required).
271 pub name: String,
272 /// Teams the project belongs to (required).
273 pub team_ids: Vec<TeamId>,
274 /// Short description.
275 #[serde(skip_serializing_if = "Option::is_none")]
276 pub description: Option<String>,
277 /// Long-form markdown content.
278 #[serde(skip_serializing_if = "Option::is_none")]
279 pub content: Option<String>,
280 /// Initial project status.
281 #[serde(skip_serializing_if = "Option::is_none")]
282 pub status_id: Option<ProjectStatusId>,
283 /// Project lead.
284 #[serde(skip_serializing_if = "Option::is_none")]
285 pub lead_id: Option<UserId>,
286 /// Project members.
287 #[serde(skip_serializing_if = "Option::is_none")]
288 pub member_ids: Option<Vec<UserId>>,
289 /// Project priority.
290 #[serde(skip_serializing_if = "Option::is_none")]
291 pub priority: Option<Priority>,
292 /// Planned start date.
293 #[serde(skip_serializing_if = "Option::is_none")]
294 pub start_date: Option<TimelessDate>,
295 /// Planned target date.
296 #[serde(skip_serializing_if = "Option::is_none")]
297 pub target_date: Option<TimelessDate>,
298 /// Icon color as a hex string.
299 #[serde(skip_serializing_if = "Option::is_none")]
300 pub color: Option<String>,
301 /// Icon name.
302 #[serde(skip_serializing_if = "Option::is_none")]
303 pub icon: Option<String>,
304}
305
306/// Input for [`ProjectsService::update`]. All fields are optional; the
307/// [`Undefinable`] fields distinguish *leave unchanged* (default) from
308/// *clear* ([`Undefinable::Null`]) from *set*.
309///
310/// ```
311/// use linear_api::Undefinable;
312/// use linear_api::projects::ProjectUpdateInput;
313///
314/// let input = ProjectUpdateInput::builder()
315/// .name("SDK v1 (renamed)".to_owned())
316/// .lead_id(Undefinable::Null) // clear the lead
317/// .build();
318/// assert_eq!(input.lead_id, Undefinable::Null);
319/// ```
320#[derive(Debug, Clone, Default, Serialize, Builder)]
321#[serde(rename_all = "camelCase")]
322#[non_exhaustive]
323pub struct ProjectUpdateInput {
324 /// New project name.
325 #[serde(skip_serializing_if = "Option::is_none")]
326 pub name: Option<String>,
327 /// New short description.
328 #[serde(skip_serializing_if = "Option::is_none")]
329 pub description: Option<String>,
330 /// New project status.
331 #[serde(skip_serializing_if = "Option::is_none")]
332 pub status_id: Option<ProjectStatusId>,
333 /// New project priority.
334 #[serde(skip_serializing_if = "Option::is_none")]
335 pub priority: Option<Priority>,
336 /// New set of teams.
337 #[serde(skip_serializing_if = "Option::is_none")]
338 pub team_ids: Option<Vec<TeamId>>,
339 /// Long-form markdown content.
340 ///
341 /// Live-verified server quirk: Linear **ignores both `null` and the
342 /// empty string** for this document-backed field — neither
343 /// [`Undefinable::Null`] nor `""` clears it; only non-empty values
344 /// update it.
345 #[serde(skip_serializing_if = "Undefinable::is_undefined", default)]
346 #[builder(default, into)]
347 pub content: Undefinable<String>,
348 /// Project lead (clearable).
349 #[serde(skip_serializing_if = "Undefinable::is_undefined", default)]
350 #[builder(default, into)]
351 pub lead_id: Undefinable<UserId>,
352 /// Planned start date (clearable).
353 #[serde(skip_serializing_if = "Undefinable::is_undefined", default)]
354 #[builder(default, into)]
355 pub start_date: Undefinable<TimelessDate>,
356 /// Planned target date (clearable).
357 #[serde(skip_serializing_if = "Undefinable::is_undefined", default)]
358 #[builder(default, into)]
359 pub target_date: Undefinable<TimelessDate>,
360}
361
362/// Input for [`ProjectsService::create_milestone`].
363///
364/// ```
365/// use linear_api::ProjectId;
366/// use linear_api::projects::ProjectMilestoneCreateInput;
367///
368/// let input = ProjectMilestoneCreateInput::builder()
369/// .project_id(ProjectId::new("proj-1"))
370/// .name("M1".to_owned())
371/// .build();
372/// assert_eq!(input.name, "M1");
373/// ```
374#[derive(Debug, Clone, Serialize, Builder)]
375#[serde(rename_all = "camelCase")]
376#[non_exhaustive]
377pub struct ProjectMilestoneCreateInput {
378 /// Project the milestone belongs to (required).
379 pub project_id: ProjectId,
380 /// Milestone name (required).
381 pub name: String,
382 /// Milestone description.
383 #[serde(skip_serializing_if = "Option::is_none")]
384 pub description: Option<String>,
385 /// Target date.
386 #[serde(skip_serializing_if = "Option::is_none")]
387 pub target_date: Option<TimelessDate>,
388 /// Sort order within the project.
389 #[serde(skip_serializing_if = "Option::is_none")]
390 pub sort_order: Option<f64>,
391}
392
393/// Input for [`ProjectsService::update_milestone`].
394///
395/// ```
396/// use linear_api::Undefinable;
397/// use linear_api::projects::ProjectMilestoneUpdateInput;
398///
399/// let input = ProjectMilestoneUpdateInput::builder()
400/// .target_date(Undefinable::Null) // clear the target date
401/// .build();
402/// assert_eq!(input.target_date, Undefinable::Null);
403/// ```
404#[derive(Debug, Clone, Default, Serialize, Builder)]
405#[serde(rename_all = "camelCase")]
406#[non_exhaustive]
407pub struct ProjectMilestoneUpdateInput {
408 /// New milestone name.
409 #[serde(skip_serializing_if = "Option::is_none")]
410 pub name: Option<String>,
411 /// New sort order.
412 #[serde(skip_serializing_if = "Option::is_none")]
413 pub sort_order: Option<f64>,
414 /// Milestone description.
415 ///
416 /// Live-verified server quirk: Linear **ignores both `null` and the
417 /// empty string** for this document-backed field — neither
418 /// [`Undefinable::Null`] nor `""` clears it; only non-empty values
419 /// update it.
420 #[serde(skip_serializing_if = "Undefinable::is_undefined", default)]
421 #[builder(default, into)]
422 pub description: Undefinable<String>,
423 /// Target date (clearable).
424 #[serde(skip_serializing_if = "Undefinable::is_undefined", default)]
425 #[builder(default, into)]
426 pub target_date: Undefinable<TimelessDate>,
427}
428
429/// A GraphQL connection as returned on the wire; converted to [`Page`].
430#[derive(Deserialize)]
431#[serde(rename_all = "camelCase")]
432struct Connection<T> {
433 nodes: Vec<T>,
434 page_info: PageInfo,
435}
436
437impl<T> From<Connection<T>> for Page<T> {
438 fn from(connection: Connection<T>) -> Self {
439 Page {
440 nodes: connection.nodes,
441 page_info: connection.page_info,
442 }
443 }
444}
445
446/// Service for projects, project statuses, and project milestones. Obtain
447/// via [`LinearClient::projects`].
448#[derive(Clone, Copy)]
449pub struct ProjectsService<'a> {
450 client: &'a LinearClient,
451}
452
453impl LinearClient {
454 /// Projects, project statuses, and project milestones.
455 ///
456 /// ```no_run
457 /// # async fn example() -> linear_api::Result<()> {
458 /// let client = linear_api::LinearClient::from_env()?;
459 /// let statuses = client.projects().statuses().await?;
460 /// # Ok(()) }
461 /// ```
462 pub fn projects(&self) -> ProjectsService<'_> {
463 ProjectsService { client: self }
464 }
465}
466
467impl<'a> ProjectsService<'a> {
468 /// Fetches one page of projects.
469 ///
470 /// ```no_run
471 /// # async fn example() -> linear_api::Result<()> {
472 /// # let client = linear_api::LinearClient::from_env()?;
473 /// use linear_api::projects::ListProjectsRequest;
474 ///
475 /// let page = client
476 /// .projects()
477 /// .list(ListProjectsRequest::builder().first(50).build())
478 /// .await?;
479 /// println!("{} projects, more: {}", page.nodes.len(), page.page_info.has_next_page);
480 /// # Ok(()) }
481 /// ```
482 pub async fn list(&self, request: ListProjectsRequest) -> Result<Page<Project>> {
483 #[derive(Deserialize)]
484 struct Data {
485 projects: Connection<Project>,
486 }
487 let data: Data = self
488 .client
489 .query("ProjectList", PROJECT_LIST, request)
490 .await?;
491 Ok(data.projects.into())
492 }
493
494 /// Lazily streams every project matching `request` across pages,
495 /// starting from `request.after` when set (the cursor then advances page
496 /// by page). See [`crate::paginate`] for the complexity trade-offs.
497 ///
498 /// ```no_run
499 /// # async fn example() -> linear_api::Result<()> {
500 /// # let client = linear_api::LinearClient::from_env()?;
501 /// use futures::TryStreamExt;
502 /// use linear_api::projects::ListProjectsRequest;
503 ///
504 /// let projects: Vec<_> = client
505 /// .projects()
506 /// .list_stream(ListProjectsRequest::builder().first(50).build())
507 /// .try_collect()
508 /// .await?;
509 /// # Ok(()) }
510 /// ```
511 pub fn list_stream(
512 &self,
513 request: ListProjectsRequest,
514 ) -> impl futures::Stream<Item = Result<Project>> + 'a {
515 let service = *self;
516 crate::pagination::paginate(move |after| {
517 let mut request = request.clone();
518 // The first call keeps a caller-seeded `request.after`; later
519 // calls advance to each page's end cursor.
520 if after.is_some() {
521 request.after = after;
522 }
523 async move { service.list(request).await }
524 })
525 }
526
527 /// Fetches a single project by ID. Linear also resolves slug IDs (the
528 /// identifier from the project URL) passed as a [`ProjectId`].
529 ///
530 /// ```no_run
531 /// # async fn example() -> linear_api::Result<()> {
532 /// # let client = linear_api::LinearClient::from_env()?;
533 /// let id = linear_api::ProjectId::new("sdk-v1-8f2a1c0d3b4e");
534 /// let project = client.projects().get(&id).await?;
535 /// println!("{} — {:.0}% done", project.name, project.progress * 100.0);
536 /// # Ok(()) }
537 /// ```
538 pub async fn get(&self, id: &ProjectId) -> Result<Project> {
539 #[derive(Deserialize)]
540 struct Data {
541 project: Project,
542 }
543 let data: Data = self
544 .client
545 .query(
546 "ProjectGet",
547 PROJECT_GET,
548 serde_json::json!({ "id": id.as_str() }),
549 )
550 .await?;
551 Ok(data.project)
552 }
553
554 /// Creates a project.
555 ///
556 /// ```no_run
557 /// # async fn example() -> linear_api::Result<()> {
558 /// # let client = linear_api::LinearClient::from_env()?;
559 /// use linear_api::TeamId;
560 /// use linear_api::projects::ProjectCreateInput;
561 ///
562 /// let project = client
563 /// .projects()
564 /// .create(
565 /// ProjectCreateInput::builder()
566 /// .name("SDK v1".to_owned())
567 /// .team_ids(vec![TeamId::new("team-1")])
568 /// .build(),
569 /// )
570 /// .await?;
571 /// # Ok(()) }
572 /// ```
573 pub async fn create(&self, input: ProjectCreateInput) -> Result<Project> {
574 #[derive(Deserialize)]
575 struct Payload {
576 success: bool,
577 project: Option<Project>,
578 }
579 #[derive(Deserialize)]
580 struct Data {
581 #[serde(rename = "projectCreate")]
582 payload: Payload,
583 }
584 let data: Data = self
585 .client
586 .mutation(
587 "ProjectCreate",
588 PROJECT_CREATE,
589 serde_json::json!({ "input": input }),
590 )
591 .await?;
592 ensure_success("ProjectCreate", data.payload.success)?;
593 data.payload.project.ok_or(Error::MissingData {
594 operation: "ProjectCreate",
595 })
596 }
597
598 /// Updates a project (Linear's `projectUpdate` **mutation**, not the
599 /// `ProjectUpdate` status-post entity). [`Undefinable`] fields on the
600 /// input distinguish leave-unchanged from clear from set.
601 ///
602 /// ```no_run
603 /// # async fn example() -> linear_api::Result<()> {
604 /// # let client = linear_api::LinearClient::from_env()?;
605 /// use linear_api::Undefinable;
606 /// use linear_api::projects::ProjectUpdateInput;
607 ///
608 /// let id = linear_api::ProjectId::new("proj-1");
609 /// let project = client
610 /// .projects()
611 /// .update(
612 /// &id,
613 /// ProjectUpdateInput::builder()
614 /// .target_date("2026-10-01".parse::<linear_api::TimelessDate>().unwrap())
615 /// .lead_id(Undefinable::Null) // clear the lead
616 /// .build(),
617 /// )
618 /// .await?;
619 /// # Ok(()) }
620 /// ```
621 pub async fn update(&self, id: &ProjectId, input: ProjectUpdateInput) -> Result<Project> {
622 #[derive(Deserialize)]
623 struct Payload {
624 success: bool,
625 project: Option<Project>,
626 }
627 #[derive(Deserialize)]
628 struct Data {
629 #[serde(rename = "projectUpdate")]
630 payload: Payload,
631 }
632 let data: Data = self
633 .client
634 .mutation(
635 "UpdateProject",
636 UPDATE_PROJECT,
637 serde_json::json!({ "id": id.as_str(), "input": input }),
638 )
639 .await?;
640 ensure_success("UpdateProject", data.payload.success)?;
641 data.payload.project.ok_or(Error::MissingData {
642 operation: "UpdateProject",
643 })
644 }
645
646 /// Archives a project.
647 ///
648 /// ```no_run
649 /// # async fn example() -> linear_api::Result<()> {
650 /// # let client = linear_api::LinearClient::from_env()?;
651 /// let id = linear_api::ProjectId::new("proj-1");
652 /// client.projects().archive(&id).await?;
653 /// # Ok(()) }
654 /// ```
655 pub async fn archive(&self, id: &ProjectId) -> Result<()> {
656 #[derive(Deserialize)]
657 struct Payload {
658 success: bool,
659 }
660 #[derive(Deserialize)]
661 struct Data {
662 #[serde(rename = "projectArchive")]
663 payload: Payload,
664 }
665 let data: Data = self
666 .client
667 .mutation(
668 "ProjectArchive",
669 PROJECT_ARCHIVE,
670 serde_json::json!({ "id": id.as_str() }),
671 )
672 .await?;
673 ensure_success("ProjectArchive", data.payload.success)
674 }
675
676 /// Fetches the workspace's project statuses (a single page of up to 50 —
677 /// workspaces define a handful).
678 ///
679 /// ```no_run
680 /// # async fn example() -> linear_api::Result<()> {
681 /// # let client = linear_api::LinearClient::from_env()?;
682 /// for status in client.projects().statuses().await? {
683 /// println!("{} ({:?})", status.name, status.status_type);
684 /// }
685 /// # Ok(()) }
686 /// ```
687 pub async fn statuses(&self) -> Result<Vec<ProjectStatus>> {
688 #[derive(Deserialize)]
689 struct Data {
690 #[serde(rename = "projectStatuses", deserialize_with = "crate::types::nodes")]
691 statuses: Vec<ProjectStatus>,
692 }
693 let data: Data = self
694 .client
695 .query("ProjectStatuses", PROJECT_STATUSES, serde_json::json!({}))
696 .await?;
697 Ok(data.statuses)
698 }
699
700 /// Fetches every milestone of a project, draining pagination internally
701 /// (pages of 50, capped at 250 milestones).
702 ///
703 /// ```no_run
704 /// # async fn example() -> linear_api::Result<()> {
705 /// # let client = linear_api::LinearClient::from_env()?;
706 /// let id = linear_api::ProjectId::new("proj-1");
707 /// for milestone in client.projects().milestones(&id).await? {
708 /// println!("{} (order {})", milestone.name, milestone.sort_order);
709 /// }
710 /// # Ok(()) }
711 /// ```
712 pub async fn milestones(&self, id: &ProjectId) -> Result<Vec<ProjectMilestone>> {
713 #[derive(Deserialize)]
714 struct ProjectNode {
715 #[serde(rename = "projectMilestones")]
716 milestones: Connection<ProjectMilestone>,
717 }
718 #[derive(Deserialize)]
719 struct Data {
720 project: ProjectNode,
721 }
722 let service = *self;
723 let id = id.clone();
724 crate::pagination::collect_all(
725 move |after| {
726 let id = id.clone();
727 async move {
728 let data: Data = service
729 .client
730 .query(
731 "ProjectMilestones",
732 PROJECT_MILESTONES,
733 serde_json::json!({ "id": id.as_str(), "first": 50, "after": after }),
734 )
735 .await?;
736 Ok(data.project.milestones.into())
737 }
738 },
739 Some(250),
740 )
741 .await
742 }
743
744 /// Creates a milestone within a project.
745 ///
746 /// ```no_run
747 /// # async fn example() -> linear_api::Result<()> {
748 /// # let client = linear_api::LinearClient::from_env()?;
749 /// use linear_api::projects::ProjectMilestoneCreateInput;
750 ///
751 /// let milestone = client
752 /// .projects()
753 /// .create_milestone(
754 /// ProjectMilestoneCreateInput::builder()
755 /// .project_id(linear_api::ProjectId::new("proj-1"))
756 /// .name("M1 — Read path".to_owned())
757 /// .build(),
758 /// )
759 /// .await?;
760 /// # Ok(()) }
761 /// ```
762 pub async fn create_milestone(
763 &self,
764 input: ProjectMilestoneCreateInput,
765 ) -> Result<ProjectMilestone> {
766 #[derive(Deserialize)]
767 struct Payload {
768 success: bool,
769 #[serde(rename = "projectMilestone")]
770 milestone: Option<ProjectMilestone>,
771 }
772 #[derive(Deserialize)]
773 struct Data {
774 #[serde(rename = "projectMilestoneCreate")]
775 payload: Payload,
776 }
777 let data: Data = self
778 .client
779 .mutation(
780 "ProjectMilestoneCreate",
781 PROJECT_MILESTONE_CREATE,
782 serde_json::json!({ "input": input }),
783 )
784 .await?;
785 ensure_success("ProjectMilestoneCreate", data.payload.success)?;
786 data.payload.milestone.ok_or(Error::MissingData {
787 operation: "ProjectMilestoneCreate",
788 })
789 }
790
791 /// Updates a milestone.
792 ///
793 /// ```no_run
794 /// # async fn example() -> linear_api::Result<()> {
795 /// # let client = linear_api::LinearClient::from_env()?;
796 /// use linear_api::Undefinable;
797 /// use linear_api::projects::ProjectMilestoneUpdateInput;
798 ///
799 /// let id = linear_api::ProjectMilestoneId::new("ms-1");
800 /// let milestone = client
801 /// .projects()
802 /// .update_milestone(
803 /// &id,
804 /// ProjectMilestoneUpdateInput::builder()
805 /// .name("M1 — Read path (done)".to_owned())
806 /// .target_date(Undefinable::Null) // clear the target date
807 /// .build(),
808 /// )
809 /// .await?;
810 /// # Ok(()) }
811 /// ```
812 pub async fn update_milestone(
813 &self,
814 id: &ProjectMilestoneId,
815 input: ProjectMilestoneUpdateInput,
816 ) -> Result<ProjectMilestone> {
817 #[derive(Deserialize)]
818 struct Payload {
819 success: bool,
820 #[serde(rename = "projectMilestone")]
821 milestone: Option<ProjectMilestone>,
822 }
823 #[derive(Deserialize)]
824 struct Data {
825 #[serde(rename = "projectMilestoneUpdate")]
826 payload: Payload,
827 }
828 let data: Data = self
829 .client
830 .mutation(
831 "ProjectMilestoneUpdate",
832 PROJECT_MILESTONE_UPDATE,
833 serde_json::json!({ "id": id.as_str(), "input": input }),
834 )
835 .await?;
836 ensure_success("ProjectMilestoneUpdate", data.payload.success)?;
837 data.payload.milestone.ok_or(Error::MissingData {
838 operation: "ProjectMilestoneUpdate",
839 })
840 }
841
842 /// Deletes a milestone.
843 ///
844 /// ```no_run
845 /// # async fn example() -> linear_api::Result<()> {
846 /// # let client = linear_api::LinearClient::from_env()?;
847 /// let id = linear_api::ProjectMilestoneId::new("ms-1");
848 /// client.projects().delete_milestone(&id).await?;
849 /// # Ok(()) }
850 /// ```
851 pub async fn delete_milestone(&self, id: &ProjectMilestoneId) -> Result<()> {
852 #[derive(Deserialize)]
853 struct Payload {
854 success: bool,
855 }
856 #[derive(Deserialize)]
857 struct Data {
858 #[serde(rename = "projectMilestoneDelete")]
859 payload: Payload,
860 }
861 let data: Data = self
862 .client
863 .mutation(
864 "ProjectMilestoneDelete",
865 PROJECT_MILESTONE_DELETE,
866 serde_json::json!({ "id": id.as_str() }),
867 )
868 .await?;
869 ensure_success("ProjectMilestoneDelete", data.payload.success)
870 }
871}