Skip to main content

xbp_cli/commands/linear_cmd/
meta.rs

1//! Linear metadata lookups (teams, states, labels, users).
2
3use crate::commands::linear::{linear_graphql_errors, linear_graphql_request};
4use serde::Deserialize;
5use serde_json::{json, Value as JsonValue};
6
7#[derive(Debug, Clone, PartialEq, Eq)]
8pub struct LinearTeam {
9    pub id: String,
10    pub key: String,
11    pub name: String,
12}
13
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct LinearWorkflowState {
16    pub id: String,
17    pub name: String,
18    pub state_type: String,
19}
20
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct LinearLabel {
23    pub id: String,
24    pub name: String,
25}
26
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct LinearUser {
29    pub id: String,
30    pub name: String,
31    pub email: Option<String>,
32    pub display_name: Option<String>,
33}
34
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct LinearProject {
37    pub id: String,
38    pub name: String,
39    pub state: Option<String>,
40}
41
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct LinearCycle {
44    pub id: String,
45    pub name: Option<String>,
46    pub number: Option<i32>,
47    pub starts_at: Option<String>,
48    pub ends_at: Option<String>,
49    pub completed_at: Option<String>,
50}
51
52impl LinearCycle {
53    pub fn display_label(&self) -> String {
54        let name = self
55            .name
56            .as_deref()
57            .map(str::trim)
58            .filter(|s| !s.is_empty());
59        let number = self.number.map(|n| format!("Cycle {n}"));
60        let base = match (name, number) {
61            (Some(n), Some(num)) => format!("{num} — {n}"),
62            (Some(n), None) => n.to_string(),
63            (None, Some(num)) => num,
64            (None, None) => self.id.clone(),
65        };
66        if self.completed_at.is_some() {
67            format!("{base} (completed)")
68        } else {
69            base
70        }
71    }
72}
73
74#[derive(Debug, Deserialize)]
75struct TeamsPage {
76    nodes: Vec<TeamNode>,
77    #[serde(rename = "pageInfo")]
78    page_info: PageInfo,
79}
80
81#[derive(Debug, Deserialize)]
82struct TeamNode {
83    id: String,
84    key: String,
85    name: String,
86}
87
88#[derive(Debug, Deserialize)]
89struct PageInfo {
90    #[serde(rename = "hasNextPage")]
91    has_next_page: bool,
92    #[serde(rename = "endCursor")]
93    end_cursor: Option<String>,
94}
95
96#[derive(Debug, Deserialize)]
97struct StatesPage {
98    nodes: Vec<StateNode>,
99}
100
101#[derive(Debug, Deserialize)]
102struct StateNode {
103    id: String,
104    name: String,
105    #[serde(rename = "type")]
106    state_type: String,
107}
108
109#[derive(Debug, Deserialize)]
110struct LabelsPage {
111    nodes: Vec<LabelNode>,
112    #[serde(rename = "pageInfo")]
113    page_info: PageInfo,
114}
115
116#[derive(Debug, Deserialize)]
117struct LabelNode {
118    id: String,
119    name: String,
120}
121
122#[derive(Debug, Deserialize)]
123struct UsersPage {
124    nodes: Vec<UserNode>,
125    #[serde(rename = "pageInfo")]
126    page_info: PageInfo,
127}
128
129#[derive(Debug, Deserialize)]
130struct UserNode {
131    id: String,
132    name: String,
133    #[serde(default)]
134    email: Option<String>,
135    #[serde(default, rename = "displayName")]
136    display_name: Option<String>,
137}
138
139pub async fn list_teams(api_key: &str) -> Result<Vec<LinearTeam>, String> {
140    let mut teams = Vec::new();
141    let mut after: Option<String> = None;
142    loop {
143        let body = linear_graphql_request(
144            api_key,
145            &json!({
146                "query": r#"
147                    query XbpLinearTeams($after: String) {
148                      teams(first: 50, after: $after, includeArchived: false) {
149                        nodes { id key name }
150                        pageInfo { hasNextPage endCursor }
151                      }
152                    }
153                "#,
154                "variables": { "after": after }
155            }),
156        )
157        .await?;
158        let page: TeamsPage = extract_data(body, "teams")?;
159        teams.extend(page.nodes.into_iter().map(|n| LinearTeam {
160            id: n.id,
161            key: n.key,
162            name: n.name,
163        }));
164        if !page.page_info.has_next_page {
165            break;
166        }
167        after = page.page_info.end_cursor;
168        if after.is_none() {
169            break;
170        }
171    }
172    Ok(teams)
173}
174
175pub async fn resolve_team_id(api_key: &str, team: &str) -> Result<String, String> {
176    let team = team.trim();
177    if team.is_empty() {
178        return Err("Team is required.".into());
179    }
180    // UUID-looking values are used as-is.
181    if team.len() >= 32 && team.contains('-') {
182        return Ok(team.to_string());
183    }
184    let teams = list_teams(api_key).await?;
185    let lower = team.to_ascii_lowercase();
186    if let Some(found) = teams.iter().find(|t| {
187        t.id == team
188            || t.key.eq_ignore_ascii_case(team)
189            || t.name.eq_ignore_ascii_case(team)
190            || t.key.to_ascii_lowercase() == lower
191            || t.name.to_ascii_lowercase() == lower
192    }) {
193        return Ok(found.id.clone());
194    }
195    Err(format!(
196        "No Linear team matching `{team}`. Use a team key, name, or id."
197    ))
198}
199
200pub async fn list_workflow_states(
201    api_key: &str,
202    team_id: &str,
203) -> Result<Vec<LinearWorkflowState>, String> {
204    let body = linear_graphql_request(
205        api_key,
206        &json!({
207            "query": r#"
208                query XbpLinearStates($teamId: String!) {
209                  team(id: $teamId) {
210                    states {
211                      nodes { id name type }
212                    }
213                  }
214                }
215            "#,
216            "variables": { "teamId": team_id }
217        }),
218    )
219    .await?;
220    linear_graphql_errors(&body)?;
221    let states = body
222        .get("data")
223        .and_then(|d| d.get("team"))
224        .and_then(|t| t.get("states"))
225        .cloned()
226        .ok_or_else(|| "Linear team states query returned no data.".to_string())?;
227    let page: StatesPage = serde_json::from_value(states)
228        .map_err(|e| format!("Failed to decode Linear states: {e}"))?;
229    Ok(page
230        .nodes
231        .into_iter()
232        .map(|n| LinearWorkflowState {
233            id: n.id,
234            name: n.name,
235            state_type: n.state_type,
236        })
237        .collect())
238}
239
240pub async fn resolve_state_id(
241    api_key: &str,
242    team_id: &str,
243    state: &str,
244) -> Result<String, String> {
245    let state = state.trim();
246    if state.is_empty() {
247        return Err("State is required.".into());
248    }
249    if state.len() >= 32 && state.contains('-') {
250        return Ok(state.to_string());
251    }
252    let states = list_workflow_states(api_key, team_id).await?;
253    let lower = state.to_ascii_lowercase();
254    if let Some(found) = states.iter().find(|s| {
255        s.id == state
256            || s.name.eq_ignore_ascii_case(state)
257            || s.state_type.eq_ignore_ascii_case(state)
258            || s.name.to_ascii_lowercase() == lower
259    }) {
260        return Ok(found.id.clone());
261    }
262    Err(format!(
263        "No workflow state matching `{state}` on this team."
264    ))
265}
266
267pub async fn list_labels(api_key: &str, team_id: Option<&str>) -> Result<Vec<LinearLabel>, String> {
268    let mut labels = Vec::new();
269    let mut after: Option<String> = None;
270    loop {
271        let body = if let Some(team_id) = team_id {
272            linear_graphql_request(
273                api_key,
274                &json!({
275                    "query": r#"
276                        query XbpLinearTeamLabels($teamId: String!, $after: String) {
277                          team(id: $teamId) {
278                            labels(first: 50, after: $after) {
279                              nodes { id name }
280                              pageInfo { hasNextPage endCursor }
281                            }
282                          }
283                        }
284                    "#,
285                    "variables": { "teamId": team_id, "after": after }
286                }),
287            )
288            .await?
289        } else {
290            linear_graphql_request(
291                api_key,
292                &json!({
293                    "query": r#"
294                        query XbpLinearLabels($after: String) {
295                          issueLabels(first: 50, after: $after) {
296                            nodes { id name }
297                            pageInfo { hasNextPage endCursor }
298                          }
299                        }
300                    "#,
301                    "variables": { "after": after }
302                }),
303            )
304            .await?
305        };
306
307        linear_graphql_errors(&body)?;
308        let page_value = if team_id.is_some() {
309            body.get("data")
310                .and_then(|d| d.get("team"))
311                .and_then(|t| t.get("labels"))
312                .cloned()
313        } else {
314            body.get("data")
315                .and_then(|d| d.get("issueLabels"))
316                .cloned()
317        }
318        .ok_or_else(|| "Linear labels query returned no data.".to_string())?;
319
320        let page: LabelsPage = serde_json::from_value(page_value)
321            .map_err(|e| format!("Failed to decode Linear labels: {e}"))?;
322        labels.extend(page.nodes.into_iter().map(|n| LinearLabel {
323            id: n.id,
324            name: n.name,
325        }));
326        if !page.page_info.has_next_page {
327            break;
328        }
329        after = page.page_info.end_cursor;
330        if after.is_none() {
331            break;
332        }
333    }
334    Ok(labels)
335}
336
337pub async fn resolve_label_ids(
338    api_key: &str,
339    team_id: Option<&str>,
340    labels: &[String],
341) -> Result<Vec<String>, String> {
342    if labels.is_empty() {
343        return Ok(Vec::new());
344    }
345    let known = list_labels(api_key, team_id).await?;
346    let mut ids = Vec::new();
347    for label in labels {
348        let label = label.trim();
349        if label.is_empty() {
350            continue;
351        }
352        if label.len() >= 32 && label.contains('-') {
353            ids.push(label.to_string());
354            continue;
355        }
356        if let Some(found) = known
357            .iter()
358            .find(|l| l.id == label || l.name.eq_ignore_ascii_case(label))
359        {
360            ids.push(found.id.clone());
361        } else {
362            return Err(format!("No Linear label matching `{label}`."));
363        }
364    }
365    Ok(ids)
366}
367
368/// Resolve label ids, creating missing labels on the team when possible.
369pub async fn ensure_label_ids(
370    api_key: &str,
371    team_id: Option<&str>,
372    labels: &[String],
373) -> Result<Vec<String>, String> {
374    if labels.is_empty() {
375        return Ok(Vec::new());
376    }
377    let mut known = list_labels(api_key, team_id).await?;
378    let mut ids = Vec::new();
379    for label in labels {
380        let label = label.trim();
381        if label.is_empty() {
382            continue;
383        }
384        if label.len() >= 32 && label.contains('-') {
385            ids.push(label.to_string());
386            continue;
387        }
388        if let Some(found) = known
389            .iter()
390            .find(|l| l.id == label || l.name.eq_ignore_ascii_case(label))
391        {
392            ids.push(found.id.clone());
393            continue;
394        }
395        // Create when we have a team context.
396        if let Some(team_id) = team_id {
397            match create_issue_label(api_key, team_id, label).await {
398                Ok(created) => {
399                    known.push(created.clone());
400                    ids.push(created.id);
401                }
402                Err(err) => {
403                    // Soft-fail: skip unknown labels rather than aborting the whole sync.
404                    eprintln!(
405                        "warning: could not create Linear label `{label}`: {err}"
406                    );
407                }
408            }
409        } else {
410            eprintln!("warning: Linear label `{label}` not found and no team to create it on");
411        }
412    }
413    Ok(ids)
414}
415
416async fn create_issue_label(
417    api_key: &str,
418    team_id: &str,
419    name: &str,
420) -> Result<LinearLabel, String> {
421    let body = linear_graphql_request(
422        api_key,
423        &json!({
424            "query": r#"
425                mutation XbpLinearLabelCreate($input: IssueLabelCreateInput!) {
426                  issueLabelCreate(input: $input) {
427                    success
428                    issueLabel { id name }
429                  }
430                }
431            "#,
432            "variables": {
433                "input": {
434                    "name": name,
435                    "teamId": team_id
436                }
437            }
438        }),
439    )
440    .await?;
441    linear_graphql_errors(&body)?;
442    let label = body
443        .get("data")
444        .and_then(|d| d.get("issueLabelCreate"))
445        .and_then(|c| c.get("issueLabel"))
446        .cloned()
447        .ok_or_else(|| "Linear issueLabelCreate returned no label.".to_string())?;
448    let id = label
449        .get("id")
450        .and_then(JsonValue::as_str)
451        .ok_or_else(|| "Linear label missing id.".to_string())?
452        .to_string();
453    let name = label
454        .get("name")
455        .and_then(JsonValue::as_str)
456        .unwrap_or(name)
457        .to_string();
458    Ok(LinearLabel { id, name })
459}
460
461/// Prefer configured team, else the only accessible team, else error.
462pub async fn resolve_team_id_auto(
463    api_key: &str,
464    preferred: Option<&str>,
465) -> Result<String, String> {
466    if let Some(preferred) = preferred.map(str::trim).filter(|s| !s.is_empty()) {
467        return resolve_team_id(api_key, preferred).await;
468    }
469    let teams = list_teams(api_key).await?;
470    if teams.len() == 1 {
471        return Ok(teams[0].id.clone());
472    }
473    if teams.is_empty() {
474        return Err("No Linear teams available for this API key.".into());
475    }
476    Err(
477        "Multiple Linear teams found. Pass `--team KEY` or set `linear.default_team_key` in `.xbp/xbp.yaml`."
478            .into(),
479    )
480}
481
482pub async fn list_users(api_key: &str) -> Result<Vec<LinearUser>, String> {
483    let mut users = Vec::new();
484    let mut after: Option<String> = None;
485    loop {
486        let body = linear_graphql_request(
487            api_key,
488            &json!({
489                "query": r#"
490                    query XbpLinearUsers($after: String) {
491                      users(first: 50, after: $after, includeDisabled: false) {
492                        nodes { id name email displayName }
493                        pageInfo { hasNextPage endCursor }
494                      }
495                    }
496                "#,
497                "variables": { "after": after }
498            }),
499        )
500        .await?;
501        let page: UsersPage = extract_data(body, "users")?;
502        users.extend(page.nodes.into_iter().map(|n| LinearUser {
503            id: n.id,
504            name: n.name,
505            email: n.email,
506            display_name: n.display_name,
507        }));
508        if !page.page_info.has_next_page {
509            break;
510        }
511        after = page.page_info.end_cursor;
512        if after.is_none() {
513            break;
514        }
515    }
516    Ok(users)
517}
518
519pub async fn resolve_assignee_id(api_key: &str, assignee: &str) -> Result<Option<String>, String> {
520    let assignee = assignee.trim();
521    if assignee.is_empty() || assignee.eq_ignore_ascii_case("none") {
522        return Ok(None);
523    }
524    if assignee.eq_ignore_ascii_case("me") {
525        return Ok(Some(viewer_id(api_key).await?));
526    }
527    if assignee.len() >= 32 && assignee.contains('-') {
528        return Ok(Some(assignee.to_string()));
529    }
530    let users = list_users(api_key).await?;
531    let lower = assignee.to_ascii_lowercase();
532    if let Some(found) = users.iter().find(|u| {
533        u.id == assignee
534            || u.name.eq_ignore_ascii_case(assignee)
535            || u.email
536                .as_deref()
537                .is_some_and(|e| e.eq_ignore_ascii_case(assignee))
538            || u.display_name
539                .as_deref()
540                .is_some_and(|d| d.eq_ignore_ascii_case(assignee))
541            || u.name.to_ascii_lowercase() == lower
542    }) {
543        return Ok(Some(found.id.clone()));
544    }
545    Err(format!("No Linear user matching `{assignee}`."))
546}
547
548async fn viewer_id(api_key: &str) -> Result<String, String> {
549    let body = linear_graphql_request(
550        api_key,
551        &json!({
552            "query": r#"query XbpLinearViewer { viewer { id } }"#
553        }),
554    )
555    .await?;
556    linear_graphql_errors(&body)?;
557    body.get("data")
558        .and_then(|d| d.get("viewer"))
559        .and_then(|v| v.get("id"))
560        .and_then(JsonValue::as_str)
561        .map(str::to_string)
562        .ok_or_else(|| "Linear viewer query returned no id.".to_string())
563}
564
565#[derive(Debug, Deserialize)]
566struct ProjectsPage {
567    nodes: Vec<ProjectNode>,
568    #[serde(rename = "pageInfo")]
569    page_info: PageInfo,
570}
571
572#[derive(Debug, Deserialize)]
573struct ProjectNode {
574    id: String,
575    name: String,
576    #[serde(default)]
577    state: Option<String>,
578}
579
580#[derive(Debug, Deserialize)]
581struct CyclesPage {
582    nodes: Vec<CycleNode>,
583    #[serde(rename = "pageInfo")]
584    page_info: PageInfo,
585}
586
587#[derive(Debug, Deserialize)]
588struct CycleNode {
589    id: String,
590    #[serde(default)]
591    name: Option<String>,
592    #[serde(default)]
593    number: Option<i32>,
594    #[serde(default, rename = "startsAt")]
595    starts_at: Option<String>,
596    #[serde(default, rename = "endsAt")]
597    ends_at: Option<String>,
598    #[serde(default, rename = "completedAt")]
599    completed_at: Option<String>,
600}
601
602pub async fn list_projects(api_key: &str) -> Result<Vec<LinearProject>, String> {
603    let mut projects = Vec::new();
604    let mut after: Option<String> = None;
605    loop {
606        let body = linear_graphql_request(
607            api_key,
608            &json!({
609                "query": r#"
610                    query XbpLinearProjects($after: String) {
611                      projects(first: 50, after: $after, includeArchived: false) {
612                        nodes { id name state }
613                        pageInfo { hasNextPage endCursor }
614                      }
615                    }
616                "#,
617                "variables": { "after": after }
618            }),
619        )
620        .await?;
621        let page: ProjectsPage = extract_data(body, "projects")?;
622        projects.extend(page.nodes.into_iter().map(|n| LinearProject {
623            id: n.id,
624            name: n.name,
625            state: n.state,
626        }));
627        if !page.page_info.has_next_page {
628            break;
629        }
630        after = page.page_info.end_cursor;
631        if after.is_none() {
632            break;
633        }
634    }
635    Ok(projects)
636}
637
638pub async fn resolve_project_id(
639    api_key: &str,
640    project: &str,
641) -> Result<Option<String>, String> {
642    let project = project.trim();
643    if project.is_empty() || project.eq_ignore_ascii_case("none") {
644        return Ok(None);
645    }
646    if project.len() >= 32 && project.contains('-') {
647        return Ok(Some(project.to_string()));
648    }
649    let projects = list_projects(api_key).await?;
650    let lower = project.to_ascii_lowercase();
651    if let Some(found) = projects.iter().find(|p| {
652        p.id == project
653            || p.name.eq_ignore_ascii_case(project)
654            || p.name.to_ascii_lowercase() == lower
655    }) {
656        return Ok(Some(found.id.clone()));
657    }
658    Err(format!("No Linear project matching `{project}`."))
659}
660
661/// List cycles for a team (newest first when Linear returns them that way).
662pub async fn list_cycles(api_key: &str, team_id: &str) -> Result<Vec<LinearCycle>, String> {
663    let mut cycles = Vec::new();
664    let mut after: Option<String> = None;
665    loop {
666        let body = linear_graphql_request(
667            api_key,
668            &json!({
669                "query": r#"
670                    query XbpLinearCycles($teamId: String!, $after: String) {
671                      team(id: $teamId) {
672                        cycles(first: 50, after: $after) {
673                          nodes { id name number startsAt endsAt completedAt }
674                          pageInfo { hasNextPage endCursor }
675                        }
676                      }
677                    }
678                "#,
679                "variables": { "teamId": team_id, "after": after }
680            }),
681        )
682        .await?;
683        linear_graphql_errors(&body)?;
684        let page_value = body
685            .get("data")
686            .and_then(|d| d.get("team"))
687            .and_then(|t| t.get("cycles"))
688            .cloned()
689            .ok_or_else(|| "Linear team cycles query returned no data.".to_string())?;
690        let page: CyclesPage = serde_json::from_value(page_value)
691            .map_err(|e| format!("Failed to decode Linear cycles: {e}"))?;
692        cycles.extend(page.nodes.into_iter().map(|n| LinearCycle {
693            id: n.id,
694            name: n.name,
695            number: n.number,
696            starts_at: n.starts_at,
697            ends_at: n.ends_at,
698            completed_at: n.completed_at,
699        }));
700        if !page.page_info.has_next_page {
701            break;
702        }
703        after = page.page_info.end_cursor;
704        if after.is_none() {
705            break;
706        }
707    }
708    Ok(cycles)
709}
710
711pub async fn resolve_cycle_id(
712    api_key: &str,
713    team_id: &str,
714    cycle: &str,
715) -> Result<Option<String>, String> {
716    let cycle = cycle.trim();
717    if cycle.is_empty() || cycle.eq_ignore_ascii_case("none") {
718        return Ok(None);
719    }
720    if cycle.len() >= 32 && cycle.contains('-') {
721        return Ok(Some(cycle.to_string()));
722    }
723    let cycles = list_cycles(api_key, team_id).await?;
724    let lower = cycle.to_ascii_lowercase();
725    if let Some(found) = cycles.iter().find(|c| {
726        c.id == cycle
727            || c.name
728                .as_deref()
729                .is_some_and(|n| n.eq_ignore_ascii_case(cycle) || n.to_ascii_lowercase() == lower)
730            || c.number
731                .map(|n| n.to_string() == cycle || format!("cycle {n}") == lower)
732                .unwrap_or(false)
733    }) {
734        return Ok(Some(found.id.clone()));
735    }
736    Err(format!("No Linear cycle matching `{cycle}` on this team."))
737}
738
739fn extract_data<T: for<'de> Deserialize<'de>>(body: JsonValue, field: &str) -> Result<T, String> {
740    linear_graphql_errors(&body)?;
741    let value = body
742        .get("data")
743        .and_then(|d| d.get(field))
744        .cloned()
745        .ok_or_else(|| format!("Linear query returned no `{field}` data."))?;
746    serde_json::from_value(value).map_err(|e| format!("Failed to decode Linear `{field}`: {e}"))
747}
748
749