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}