Skip to main content

linear_api/
workspace.rs

1//! Teams, users, and workflow states — the workspace lookup surface every
2//! board and plan operation needs (resolving team keys, assignees, and state
3//! names to the IDs that issue and project mutations require).
4
5use bon::Builder;
6use futures::Stream;
7use serde::{Deserialize, Serialize};
8
9use crate::client::LinearClient;
10use crate::error::Result;
11use crate::filter::{IdComparator, TeamFilter, UserFilter, WorkflowStateFilter};
12use crate::ids::{TeamId, UserId, WorkflowStateId};
13use crate::pagination::{Page, PageInfo};
14use crate::types::{TeamRef, WorkflowStateType};
15
16// ---------------------------------------------------------------------------
17// GraphQL documents
18// ---------------------------------------------------------------------------
19
20/// Canonical `TeamFields` fragment — field set MUST match [`Team`].
21macro_rules! team_fields_fragment {
22    () => {
23        "fragment TeamFields on Team { id key name description color icon private timezone \
24         cyclesEnabled issueEstimationType defaultIssueEstimate }"
25    };
26}
27
28/// Canonical `UserFields` fragment — field set MUST match [`User`].
29macro_rules! user_fields_fragment {
30    () => {
31        "fragment UserFields on User { id name displayName email active admin guest avatarUrl \
32         timezone }"
33    };
34}
35
36/// Canonical `WorkflowStateFields` fragment — field set MUST match
37/// [`WorkflowState`]. Embeds the crate-wide `TeamRefFields` fragment
38/// (see [`TeamRef`]).
39macro_rules! workflow_state_fields_fragment {
40    () => {
41        "fragment WorkflowStateFields on WorkflowState { id name type position color \
42         description team { ...TeamRefFields } } \
43         fragment TeamRefFields on Team { id key name }"
44    };
45}
46
47const TEAM_LIST: &str = concat!(
48    "query TeamList($filter: TeamFilter, $first: Int, $after: String, \
49     $includeArchived: Boolean) { \
50     teams(filter: $filter, first: $first, after: $after, includeArchived: $includeArchived) { \
51     nodes { ...TeamFields } \
52     pageInfo { hasNextPage hasPreviousPage startCursor endCursor } } } ",
53    team_fields_fragment!()
54);
55
56const TEAM_GET: &str = concat!(
57    "query TeamGet($id: String!) { team(id: $id) { ...TeamFields } } ",
58    team_fields_fragment!()
59);
60
61const WORKFLOW_STATE_LIST: &str = concat!(
62    "query WorkflowStateList($filter: WorkflowStateFilter, $first: Int, $after: String) { \
63     workflowStates(filter: $filter, first: $first, after: $after) { \
64     nodes { ...WorkflowStateFields } \
65     pageInfo { hasNextPage hasPreviousPage startCursor endCursor } } } ",
66    workflow_state_fields_fragment!()
67);
68
69const USER_LIST: &str = concat!(
70    "query UserList($filter: UserFilter, $first: Int, $after: String, \
71     $includeDisabled: Boolean) { \
72     users(filter: $filter, first: $first, after: $after, includeDisabled: $includeDisabled) { \
73     nodes { ...UserFields } \
74     pageInfo { hasNextPage hasPreviousPage startCursor endCursor } } } ",
75    user_fields_fragment!()
76);
77
78const USER_GET: &str = concat!(
79    "query UserGet($id: String!) { user(id: $id) { ...UserFields } } ",
80    user_fields_fragment!()
81);
82
83pub(crate) const DOCUMENTS: &[(&str, &str)] = &[
84    ("TeamList", TEAM_LIST),
85    ("TeamGet", TEAM_GET),
86    ("WorkflowStateList", WORKFLOW_STATE_LIST),
87    ("UserList", USER_LIST),
88    ("UserGet", USER_GET),
89];
90
91// ---------------------------------------------------------------------------
92// Models
93// ---------------------------------------------------------------------------
94
95/// A Linear team.
96///
97/// # Example
98///
99/// ```no_run
100/// # async fn example() -> linear_api::Result<()> {
101/// let client = linear_api::LinearClient::from_env()?;
102/// let team: linear_api::workspace::Team = client.teams().get(&"ENG".into()).await?;
103/// println!("{} ({}) cycles={}", team.name, team.key, team.cycles_enabled);
104/// # Ok(()) }
105/// ```
106#[derive(Debug, Clone, Deserialize)]
107#[serde(rename_all = "camelCase")]
108#[non_exhaustive]
109pub struct Team {
110    /// Team ID.
111    pub id: TeamId,
112    /// Team key used in issue identifiers, e.g. `"ENG"` in `ENG-123`.
113    pub key: String,
114    /// Team name.
115    pub name: String,
116    /// Team description.
117    pub description: Option<String>,
118    /// Team color as a hex string.
119    pub color: Option<String>,
120    /// Team icon name.
121    pub icon: Option<String>,
122    /// Whether the team is private.
123    pub private: bool,
124    /// The team's timezone, e.g. `"America/Sao_Paulo"`.
125    pub timezone: String,
126    /// Whether cycles are enabled for the team.
127    pub cycles_enabled: bool,
128    /// The estimation scale in use, e.g. `"exponential"` or `"notUsed"`.
129    pub issue_estimation_type: String,
130    /// Default estimate applied to unestimated issues.
131    pub default_issue_estimate: f64,
132}
133
134/// One issue status of a team's board (a board column).
135///
136/// # Example
137///
138/// ```no_run
139/// # async fn example() -> linear_api::Result<()> {
140/// use linear_api::WorkflowStateType;
141///
142/// let client = linear_api::LinearClient::from_env()?;
143/// let team_id = linear_api::TeamId::new("88888888-8888-4888-8888-888888888888");
144/// let states = client.teams().states(&team_id).await?;
145/// let started = states
146///     .iter()
147///     .find(|s| s.state_type == WorkflowStateType::Started);
148/// println!("{:?}", started.map(|s| &s.id));
149/// # Ok(()) }
150/// ```
151#[derive(Debug, Clone, Deserialize)]
152#[serde(rename_all = "camelCase")]
153#[non_exhaustive]
154pub struct WorkflowState {
155    /// Workflow state ID.
156    pub id: WorkflowStateId,
157    /// Display name, e.g. `"In Progress"`.
158    pub name: String,
159    /// The semantic category of the state
160    /// (`triage` | `backlog` | `unstarted` | `started` | `completed` |
161    /// `canceled`). Unknown future categories deserialize as
162    /// [`WorkflowStateType::Unrecognized`].
163    #[serde(rename = "type")]
164    pub state_type: WorkflowStateType,
165    /// Board position; lower comes first (leftmost column).
166    pub position: f64,
167    /// State color as a hex string.
168    pub color: String,
169    /// State description.
170    pub description: Option<String>,
171    /// The team this state belongs to.
172    pub team: TeamRef,
173}
174
175/// A Linear user (workspace member).
176///
177/// # Example
178///
179/// ```no_run
180/// # async fn example() -> linear_api::Result<()> {
181/// let client = linear_api::LinearClient::from_env()?;
182/// let user_id = linear_api::UserId::new("9c2c88a6-99d3-4a63-a201-8ee5c7dcc374");
183/// let user: linear_api::workspace::User = client.users().get(&user_id).await?;
184/// println!("{} <{}>", user.display_name, user.email);
185/// # Ok(()) }
186/// ```
187#[derive(Debug, Clone, Deserialize)]
188#[serde(rename_all = "camelCase")]
189#[non_exhaustive]
190pub struct User {
191    /// User ID.
192    pub id: UserId,
193    /// Full name.
194    pub name: String,
195    /// Display (handle) name.
196    pub display_name: String,
197    /// Email address.
198    pub email: String,
199    /// Whether the account is active.
200    pub active: bool,
201    /// Whether the user is a workspace admin.
202    pub admin: bool,
203    /// Whether the user is a guest with restricted team access.
204    pub guest: bool,
205    /// Avatar image URL.
206    pub avatar_url: Option<String>,
207    /// The user's timezone, when set.
208    pub timezone: Option<String>,
209}
210
211// ---------------------------------------------------------------------------
212// Requests
213// ---------------------------------------------------------------------------
214
215/// Parameters for [`TeamsService::list`]. Serialized directly as the
216/// `TeamList` query variables.
217///
218/// # Example
219///
220/// ```
221/// use linear_api::{StringComparator, TeamFilter};
222/// use linear_api::workspace::ListTeamsRequest;
223///
224/// let req = ListTeamsRequest::builder()
225///     .filter(
226///         TeamFilter::builder()
227///             .key(StringComparator::builder().eq("ENG".to_string()).build())
228///             .build(),
229///     )
230///     .first(10)
231///     .build();
232/// ```
233#[derive(Debug, Clone, Default, Serialize, Builder)]
234#[serde(rename_all = "camelCase")]
235#[non_exhaustive]
236pub struct ListTeamsRequest {
237    /// Filter the returned teams.
238    #[serde(skip_serializing_if = "Option::is_none")]
239    pub filter: Option<TeamFilter>,
240    /// Page size (server default 50).
241    #[serde(skip_serializing_if = "Option::is_none")]
242    pub first: Option<i32>,
243    /// Cursor to paginate after (a previous page's `end_cursor`).
244    #[serde(skip_serializing_if = "Option::is_none")]
245    #[builder(into)]
246    pub after: Option<String>,
247    /// Include archived teams (server default `false`).
248    #[serde(skip_serializing_if = "Option::is_none")]
249    pub include_archived: Option<bool>,
250}
251
252/// Parameters for [`WorkflowStatesService::list`]. Serialized directly as
253/// the `WorkflowStateList` query variables.
254///
255/// # Example
256///
257/// ```
258/// use linear_api::{StringComparator, WorkflowStateFilter};
259/// use linear_api::workspace::ListWorkflowStatesRequest;
260///
261/// let req = ListWorkflowStatesRequest::builder()
262///     .filter(
263///         WorkflowStateFilter::builder()
264///             .r#type(StringComparator::builder().eq("started".to_string()).build())
265///             .build(),
266///     )
267///     .build();
268/// ```
269#[derive(Debug, Clone, Default, Serialize, Builder)]
270#[serde(rename_all = "camelCase")]
271#[non_exhaustive]
272pub struct ListWorkflowStatesRequest {
273    /// Filter the returned workflow states.
274    #[serde(skip_serializing_if = "Option::is_none")]
275    pub filter: Option<WorkflowStateFilter>,
276    /// Page size (server default 50).
277    #[serde(skip_serializing_if = "Option::is_none")]
278    pub first: Option<i32>,
279    /// Cursor to paginate after (a previous page's `end_cursor`).
280    #[serde(skip_serializing_if = "Option::is_none")]
281    #[builder(into)]
282    pub after: Option<String>,
283}
284
285/// Parameters for [`UsersService::list`]. Serialized directly as the
286/// `UserList` query variables.
287///
288/// # Example
289///
290/// ```
291/// use linear_api::{StringComparator, UserFilter};
292/// use linear_api::workspace::ListUsersRequest;
293///
294/// let req = ListUsersRequest::builder()
295///     .filter(
296///         UserFilter::builder()
297///             .email(StringComparator::builder().eq("ada@example.com".to_string()).build())
298///             .build(),
299///     )
300///     .build();
301/// ```
302#[derive(Debug, Clone, Default, Serialize, Builder)]
303#[serde(rename_all = "camelCase")]
304#[non_exhaustive]
305pub struct ListUsersRequest {
306    /// Filter the returned users.
307    #[serde(skip_serializing_if = "Option::is_none")]
308    pub filter: Option<UserFilter>,
309    /// Page size (server default 50).
310    #[serde(skip_serializing_if = "Option::is_none")]
311    pub first: Option<i32>,
312    /// Cursor to paginate after (a previous page's `end_cursor`).
313    #[serde(skip_serializing_if = "Option::is_none")]
314    #[builder(into)]
315    pub after: Option<String>,
316    /// Include disabled/suspended users (server default `false`).
317    #[serde(skip_serializing_if = "Option::is_none")]
318    pub include_disabled: Option<bool>,
319}
320
321// ---------------------------------------------------------------------------
322// Services
323// ---------------------------------------------------------------------------
324
325/// GraphQL connection shape shared by this module's list queries.
326#[derive(Deserialize)]
327#[serde(rename_all = "camelCase")]
328struct Connection<T> {
329    nodes: Vec<T>,
330    page_info: PageInfo,
331}
332
333impl<T> From<Connection<T>> for Page<T> {
334    fn from(connection: Connection<T>) -> Self {
335        Page {
336            nodes: connection.nodes,
337            page_info: connection.page_info,
338        }
339    }
340}
341
342/// Operations on teams. Obtain via [`LinearClient::teams`].
343#[derive(Debug, Clone, Copy)]
344pub struct TeamsService<'a> {
345    client: &'a LinearClient,
346}
347
348/// Operations on users. Obtain via [`LinearClient::users`].
349#[derive(Debug, Clone, Copy)]
350pub struct UsersService<'a> {
351    client: &'a LinearClient,
352}
353
354/// Operations on workflow states. Obtain via
355/// [`LinearClient::workflow_states`].
356#[derive(Debug, Clone, Copy)]
357pub struct WorkflowStatesService<'a> {
358    client: &'a LinearClient,
359}
360
361impl LinearClient {
362    /// Teams: list, get, and per-team workflow states.
363    ///
364    /// # Example
365    ///
366    /// ```no_run
367    /// # async fn example() -> linear_api::Result<()> {
368    /// let client = linear_api::LinearClient::from_env()?;
369    /// let page = client.teams().list(Default::default()).await?;
370    /// for team in &page.nodes {
371    ///     println!("{}: {}", team.key, team.name);
372    /// }
373    /// # Ok(()) }
374    /// ```
375    pub fn teams(&self) -> TeamsService<'_> {
376        TeamsService { client: self }
377    }
378
379    /// Users: list and get workspace members.
380    ///
381    /// # Example
382    ///
383    /// ```no_run
384    /// # async fn example() -> linear_api::Result<()> {
385    /// let client = linear_api::LinearClient::from_env()?;
386    /// let page = client.users().list(Default::default()).await?;
387    /// println!("{} users on the first page", page.nodes.len());
388    /// # Ok(()) }
389    /// ```
390    pub fn users(&self) -> UsersService<'_> {
391        UsersService { client: self }
392    }
393
394    /// Workflow states (issue statuses) across the workspace.
395    ///
396    /// For the states of one team, prefer [`TeamsService::states`].
397    ///
398    /// # Example
399    ///
400    /// ```no_run
401    /// # async fn example() -> linear_api::Result<()> {
402    /// let client = linear_api::LinearClient::from_env()?;
403    /// let page = client.workflow_states().list(Default::default()).await?;
404    /// println!("{} states on the first page", page.nodes.len());
405    /// # Ok(()) }
406    /// ```
407    pub fn workflow_states(&self) -> WorkflowStatesService<'_> {
408        WorkflowStatesService { client: self }
409    }
410}
411
412impl<'a> TeamsService<'a> {
413    /// Fetches one page of teams.
414    ///
415    /// # Example
416    ///
417    /// ```no_run
418    /// # async fn example() -> linear_api::Result<()> {
419    /// use linear_api::workspace::ListTeamsRequest;
420    ///
421    /// let client = linear_api::LinearClient::from_env()?;
422    /// let page = client
423    ///     .teams()
424    ///     .list(ListTeamsRequest::builder().first(50).build())
425    ///     .await?;
426    /// println!("has more: {}", page.page_info.has_next_page);
427    /// # Ok(()) }
428    /// ```
429    pub async fn list(&self, req: ListTeamsRequest) -> Result<Page<Team>> {
430        #[derive(Deserialize)]
431        struct Data {
432            teams: Connection<Team>,
433        }
434        let data: Data = self.client.query("TeamList", TEAM_LIST, req).await?;
435        Ok(data.teams.into())
436    }
437
438    /// Lazily streams every team matching the request across pages, starting
439    /// from `req.after` when set (the cursor then advances page by page).
440    ///
441    /// # Example
442    ///
443    /// ```no_run
444    /// # async fn example() -> linear_api::Result<()> {
445    /// use futures::TryStreamExt;
446    ///
447    /// let client = linear_api::LinearClient::from_env()?;
448    /// let teams: Vec<_> = client
449    ///     .teams()
450    ///     .list_stream(Default::default())
451    ///     .try_collect()
452    ///     .await?;
453    /// # Ok(()) }
454    /// ```
455    pub fn list_stream(self, req: ListTeamsRequest) -> impl Stream<Item = Result<Team>> + 'a {
456        crate::pagination::paginate(move |after| {
457            let mut req = req.clone();
458            // The first call keeps a caller-seeded `req.after`; later calls
459            // advance to each page's end cursor.
460            if after.is_some() {
461                req.after = after;
462            }
463            async move { self.list(req).await }
464        })
465    }
466
467    /// Fetches a single team by ID.
468    ///
469    /// Linear also resolves a team **key** (e.g. `"ENG"`) passed as the ID
470    /// string.
471    ///
472    /// # Example
473    ///
474    /// ```no_run
475    /// # async fn example() -> linear_api::Result<()> {
476    /// let client = linear_api::LinearClient::from_env()?;
477    /// let team = client.teams().get(&linear_api::TeamId::new("ENG")).await?;
478    /// println!("{}", team.name);
479    /// # Ok(()) }
480    /// ```
481    pub async fn get(&self, id: &TeamId) -> Result<Team> {
482        #[derive(Deserialize)]
483        struct Data {
484            team: Team,
485        }
486        let data: Data = self
487            .client
488            .query(
489                "TeamGet",
490                TEAM_GET,
491                serde_json::json!({ "id": id.as_str() }),
492            )
493            .await?;
494        Ok(data.team)
495    }
496
497    /// Fetches **all** workflow states of one team, sorted by board
498    /// `position` ascending (leftmost column first).
499    ///
500    /// This is how you resolve state **names** to `stateId`s before issue
501    /// updates. The semantic key is [`WorkflowState::state_type`]
502    /// (`triage` | `backlog` | `unstarted` | `started` | `completed` |
503    /// `canceled`) rather than the display name — for example, an automation might refuse
504    /// writes that would move issues into `completed`/`canceled` states.
505    ///
506    /// Drains the `WorkflowStateList` query (capped at 100 states; teams
507    /// have far fewer in practice).
508    ///
509    /// # Example
510    ///
511    /// ```no_run
512    /// # async fn example() -> linear_api::Result<()> {
513    /// let client = linear_api::LinearClient::from_env()?;
514    /// let team_id = linear_api::TeamId::new("88888888-8888-4888-8888-888888888888");
515    /// for state in client.teams().states(&team_id).await? {
516    ///     println!("{:>12} {:?}", state.name, state.state_type);
517    /// }
518    /// # Ok(()) }
519    /// ```
520    pub async fn states(&self, team: &TeamId) -> Result<Vec<WorkflowState>> {
521        let filter = WorkflowStateFilter {
522            team: Some(TeamFilter {
523                id: Some(IdComparator {
524                    eq: Some(team.to_string()),
525                    ..Default::default()
526                }),
527                ..Default::default()
528            }),
529            ..Default::default()
530        };
531        let service = self.client.workflow_states();
532        let mut states = crate::pagination::collect_all(
533            move |after| {
534                let req = ListWorkflowStatesRequest {
535                    filter: Some(filter.clone()),
536                    first: Some(100),
537                    after,
538                };
539                async move { service.list(req).await }
540            },
541            Some(100),
542        )
543        .await?;
544        states.sort_by(|a, b| a.position.total_cmp(&b.position));
545        Ok(states)
546    }
547}
548
549impl WorkflowStatesService<'_> {
550    /// Fetches one page of workflow states across the workspace.
551    ///
552    /// # Example
553    ///
554    /// ```no_run
555    /// # async fn example() -> linear_api::Result<()> {
556    /// use linear_api::workspace::ListWorkflowStatesRequest;
557    ///
558    /// let client = linear_api::LinearClient::from_env()?;
559    /// let page = client
560    ///     .workflow_states()
561    ///     .list(ListWorkflowStatesRequest::builder().first(50).build())
562    ///     .await?;
563    /// println!("{} states", page.nodes.len());
564    /// # Ok(()) }
565    /// ```
566    pub async fn list(&self, req: ListWorkflowStatesRequest) -> Result<Page<WorkflowState>> {
567        #[derive(Deserialize)]
568        struct Data {
569            #[serde(rename = "workflowStates")]
570            workflow_states: Connection<WorkflowState>,
571        }
572        let data: Data = self
573            .client
574            .query("WorkflowStateList", WORKFLOW_STATE_LIST, req)
575            .await?;
576        Ok(data.workflow_states.into())
577    }
578}
579
580impl<'a> UsersService<'a> {
581    /// Fetches one page of users.
582    ///
583    /// # Example
584    ///
585    /// ```no_run
586    /// # async fn example() -> linear_api::Result<()> {
587    /// use linear_api::workspace::ListUsersRequest;
588    ///
589    /// let client = linear_api::LinearClient::from_env()?;
590    /// let page = client
591    ///     .users()
592    ///     .list(ListUsersRequest::builder().first(50).build())
593    ///     .await?;
594    /// println!("has more: {}", page.page_info.has_next_page);
595    /// # Ok(()) }
596    /// ```
597    pub async fn list(&self, req: ListUsersRequest) -> Result<Page<User>> {
598        #[derive(Deserialize)]
599        struct Data {
600            users: Connection<User>,
601        }
602        let data: Data = self.client.query("UserList", USER_LIST, req).await?;
603        Ok(data.users.into())
604    }
605
606    /// Lazily streams every user matching the request across pages, starting
607    /// from `req.after` when set (the cursor then advances page by page).
608    ///
609    /// # Example
610    ///
611    /// ```no_run
612    /// # async fn example() -> linear_api::Result<()> {
613    /// use futures::TryStreamExt;
614    ///
615    /// let client = linear_api::LinearClient::from_env()?;
616    /// let users: Vec<_> = client
617    ///     .users()
618    ///     .list_stream(Default::default())
619    ///     .try_collect()
620    ///     .await?;
621    /// # Ok(()) }
622    /// ```
623    pub fn list_stream(self, req: ListUsersRequest) -> impl Stream<Item = Result<User>> + 'a {
624        crate::pagination::paginate(move |after| {
625            let mut req = req.clone();
626            // The first call keeps a caller-seeded `req.after`; later calls
627            // advance to each page's end cursor.
628            if after.is_some() {
629                req.after = after;
630            }
631            async move { self.list(req).await }
632        })
633    }
634
635    /// Fetches a single user by ID. For the authenticated user, use
636    /// [`LinearClient::viewer`] instead.
637    ///
638    /// # Example
639    ///
640    /// ```no_run
641    /// # async fn example() -> linear_api::Result<()> {
642    /// let client = linear_api::LinearClient::from_env()?;
643    /// let user_id = linear_api::UserId::new("9c2c88a6-99d3-4a63-a201-8ee5c7dcc374");
644    /// let user = client.users().get(&user_id).await?;
645    /// println!("{}", user.display_name);
646    /// # Ok(()) }
647    /// ```
648    pub async fn get(&self, id: &UserId) -> Result<User> {
649        #[derive(Deserialize)]
650        struct Data {
651            user: User,
652        }
653        let data: Data = self
654            .client
655            .query(
656                "UserGet",
657                USER_GET,
658                serde_json::json!({ "id": id.as_str() }),
659            )
660            .await?;
661        Ok(data.user)
662    }
663}