Skip to main content

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}