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, Deserialize)]
36struct TeamsPage {
37    nodes: Vec<TeamNode>,
38    #[serde(rename = "pageInfo")]
39    page_info: PageInfo,
40}
41
42#[derive(Debug, Deserialize)]
43struct TeamNode {
44    id: String,
45    key: String,
46    name: String,
47}
48
49#[derive(Debug, Deserialize)]
50struct PageInfo {
51    #[serde(rename = "hasNextPage")]
52    has_next_page: bool,
53    #[serde(rename = "endCursor")]
54    end_cursor: Option<String>,
55}
56
57#[derive(Debug, Deserialize)]
58struct StatesPage {
59    nodes: Vec<StateNode>,
60}
61
62#[derive(Debug, Deserialize)]
63struct StateNode {
64    id: String,
65    name: String,
66    #[serde(rename = "type")]
67    state_type: String,
68}
69
70#[derive(Debug, Deserialize)]
71struct LabelsPage {
72    nodes: Vec<LabelNode>,
73    #[serde(rename = "pageInfo")]
74    page_info: PageInfo,
75}
76
77#[derive(Debug, Deserialize)]
78struct LabelNode {
79    id: String,
80    name: String,
81}
82
83#[derive(Debug, Deserialize)]
84struct UsersPage {
85    nodes: Vec<UserNode>,
86    #[serde(rename = "pageInfo")]
87    page_info: PageInfo,
88}
89
90#[derive(Debug, Deserialize)]
91struct UserNode {
92    id: String,
93    name: String,
94    #[serde(default)]
95    email: Option<String>,
96    #[serde(default, rename = "displayName")]
97    display_name: Option<String>,
98}
99
100pub async fn list_teams(api_key: &str) -> Result<Vec<LinearTeam>, String> {
101    let mut teams = Vec::new();
102    let mut after: Option<String> = None;
103    loop {
104        let body = linear_graphql_request(
105            api_key,
106            &json!({
107                "query": r#"
108                    query XbpLinearTeams($after: String) {
109                      teams(first: 50, after: $after, includeArchived: false) {
110                        nodes { id key name }
111                        pageInfo { hasNextPage endCursor }
112                      }
113                    }
114                "#,
115                "variables": { "after": after }
116            }),
117        )
118        .await?;
119        let page: TeamsPage = extract_data(body, "teams")?;
120        teams.extend(page.nodes.into_iter().map(|n| LinearTeam {
121            id: n.id,
122            key: n.key,
123            name: n.name,
124        }));
125        if !page.page_info.has_next_page {
126            break;
127        }
128        after = page.page_info.end_cursor;
129        if after.is_none() {
130            break;
131        }
132    }
133    Ok(teams)
134}
135
136pub async fn resolve_team_id(api_key: &str, team: &str) -> Result<String, String> {
137    let team = team.trim();
138    if team.is_empty() {
139        return Err("Team is required.".into());
140    }
141    // UUID-looking values are used as-is.
142    if team.len() >= 32 && team.contains('-') {
143        return Ok(team.to_string());
144    }
145    let teams = list_teams(api_key).await?;
146    let lower = team.to_ascii_lowercase();
147    if let Some(found) = teams.iter().find(|t| {
148        t.id == team
149            || t.key.eq_ignore_ascii_case(team)
150            || t.name.eq_ignore_ascii_case(team)
151            || t.key.to_ascii_lowercase() == lower
152            || t.name.to_ascii_lowercase() == lower
153    }) {
154        return Ok(found.id.clone());
155    }
156    Err(format!(
157        "No Linear team matching `{team}`. Use a team key, name, or id."
158    ))
159}
160
161pub async fn list_workflow_states(
162    api_key: &str,
163    team_id: &str,
164) -> Result<Vec<LinearWorkflowState>, String> {
165    let body = linear_graphql_request(
166        api_key,
167        &json!({
168            "query": r#"
169                query XbpLinearStates($teamId: String!) {
170                  team(id: $teamId) {
171                    states {
172                      nodes { id name type }
173                    }
174                  }
175                }
176            "#,
177            "variables": { "teamId": team_id }
178        }),
179    )
180    .await?;
181    linear_graphql_errors(&body)?;
182    let states = body
183        .get("data")
184        .and_then(|d| d.get("team"))
185        .and_then(|t| t.get("states"))
186        .cloned()
187        .ok_or_else(|| "Linear team states query returned no data.".to_string())?;
188    let page: StatesPage = serde_json::from_value(states)
189        .map_err(|e| format!("Failed to decode Linear states: {e}"))?;
190    Ok(page
191        .nodes
192        .into_iter()
193        .map(|n| LinearWorkflowState {
194            id: n.id,
195            name: n.name,
196            state_type: n.state_type,
197        })
198        .collect())
199}
200
201pub async fn resolve_state_id(
202    api_key: &str,
203    team_id: &str,
204    state: &str,
205) -> Result<String, String> {
206    let state = state.trim();
207    if state.is_empty() {
208        return Err("State is required.".into());
209    }
210    if state.len() >= 32 && state.contains('-') {
211        return Ok(state.to_string());
212    }
213    let states = list_workflow_states(api_key, team_id).await?;
214    let lower = state.to_ascii_lowercase();
215    if let Some(found) = states.iter().find(|s| {
216        s.id == state
217            || s.name.eq_ignore_ascii_case(state)
218            || s.state_type.eq_ignore_ascii_case(state)
219            || s.name.to_ascii_lowercase() == lower
220    }) {
221        return Ok(found.id.clone());
222    }
223    Err(format!(
224        "No workflow state matching `{state}` on this team."
225    ))
226}
227
228pub async fn list_labels(api_key: &str, team_id: Option<&str>) -> Result<Vec<LinearLabel>, String> {
229    let mut labels = Vec::new();
230    let mut after: Option<String> = None;
231    loop {
232        let body = if let Some(team_id) = team_id {
233            linear_graphql_request(
234                api_key,
235                &json!({
236                    "query": r#"
237                        query XbpLinearTeamLabels($teamId: String!, $after: String) {
238                          team(id: $teamId) {
239                            labels(first: 50, after: $after) {
240                              nodes { id name }
241                              pageInfo { hasNextPage endCursor }
242                            }
243                          }
244                        }
245                    "#,
246                    "variables": { "teamId": team_id, "after": after }
247                }),
248            )
249            .await?
250        } else {
251            linear_graphql_request(
252                api_key,
253                &json!({
254                    "query": r#"
255                        query XbpLinearLabels($after: String) {
256                          issueLabels(first: 50, after: $after) {
257                            nodes { id name }
258                            pageInfo { hasNextPage endCursor }
259                          }
260                        }
261                    "#,
262                    "variables": { "after": after }
263                }),
264            )
265            .await?
266        };
267
268        linear_graphql_errors(&body)?;
269        let page_value = if team_id.is_some() {
270            body.get("data")
271                .and_then(|d| d.get("team"))
272                .and_then(|t| t.get("labels"))
273                .cloned()
274        } else {
275            body.get("data")
276                .and_then(|d| d.get("issueLabels"))
277                .cloned()
278        }
279        .ok_or_else(|| "Linear labels query returned no data.".to_string())?;
280
281        let page: LabelsPage = serde_json::from_value(page_value)
282            .map_err(|e| format!("Failed to decode Linear labels: {e}"))?;
283        labels.extend(page.nodes.into_iter().map(|n| LinearLabel {
284            id: n.id,
285            name: n.name,
286        }));
287        if !page.page_info.has_next_page {
288            break;
289        }
290        after = page.page_info.end_cursor;
291        if after.is_none() {
292            break;
293        }
294    }
295    Ok(labels)
296}
297
298pub async fn resolve_label_ids(
299    api_key: &str,
300    team_id: Option<&str>,
301    labels: &[String],
302) -> Result<Vec<String>, String> {
303    if labels.is_empty() {
304        return Ok(Vec::new());
305    }
306    let known = list_labels(api_key, team_id).await?;
307    let mut ids = Vec::new();
308    for label in labels {
309        let label = label.trim();
310        if label.is_empty() {
311            continue;
312        }
313        if label.len() >= 32 && label.contains('-') {
314            ids.push(label.to_string());
315            continue;
316        }
317        if let Some(found) = known
318            .iter()
319            .find(|l| l.id == label || l.name.eq_ignore_ascii_case(label))
320        {
321            ids.push(found.id.clone());
322        } else {
323            return Err(format!("No Linear label matching `{label}`."));
324        }
325    }
326    Ok(ids)
327}
328
329/// Resolve label ids, creating missing labels on the team when possible.
330pub async fn ensure_label_ids(
331    api_key: &str,
332    team_id: Option<&str>,
333    labels: &[String],
334) -> Result<Vec<String>, String> {
335    if labels.is_empty() {
336        return Ok(Vec::new());
337    }
338    let mut known = list_labels(api_key, team_id).await?;
339    let mut ids = Vec::new();
340    for label in labels {
341        let label = label.trim();
342        if label.is_empty() {
343            continue;
344        }
345        if label.len() >= 32 && label.contains('-') {
346            ids.push(label.to_string());
347            continue;
348        }
349        if let Some(found) = known
350            .iter()
351            .find(|l| l.id == label || l.name.eq_ignore_ascii_case(label))
352        {
353            ids.push(found.id.clone());
354            continue;
355        }
356        // Create when we have a team context.
357        if let Some(team_id) = team_id {
358            match create_issue_label(api_key, team_id, label).await {
359                Ok(created) => {
360                    known.push(created.clone());
361                    ids.push(created.id);
362                }
363                Err(err) => {
364                    // Soft-fail: skip unknown labels rather than aborting the whole sync.
365                    eprintln!(
366                        "warning: could not create Linear label `{label}`: {err}"
367                    );
368                }
369            }
370        } else {
371            eprintln!("warning: Linear label `{label}` not found and no team to create it on");
372        }
373    }
374    Ok(ids)
375}
376
377async fn create_issue_label(
378    api_key: &str,
379    team_id: &str,
380    name: &str,
381) -> Result<LinearLabel, String> {
382    let body = linear_graphql_request(
383        api_key,
384        &json!({
385            "query": r#"
386                mutation XbpLinearLabelCreate($input: IssueLabelCreateInput!) {
387                  issueLabelCreate(input: $input) {
388                    success
389                    issueLabel { id name }
390                  }
391                }
392            "#,
393            "variables": {
394                "input": {
395                    "name": name,
396                    "teamId": team_id
397                }
398            }
399        }),
400    )
401    .await?;
402    linear_graphql_errors(&body)?;
403    let label = body
404        .get("data")
405        .and_then(|d| d.get("issueLabelCreate"))
406        .and_then(|c| c.get("issueLabel"))
407        .cloned()
408        .ok_or_else(|| "Linear issueLabelCreate returned no label.".to_string())?;
409    let id = label
410        .get("id")
411        .and_then(JsonValue::as_str)
412        .ok_or_else(|| "Linear label missing id.".to_string())?
413        .to_string();
414    let name = label
415        .get("name")
416        .and_then(JsonValue::as_str)
417        .unwrap_or(name)
418        .to_string();
419    Ok(LinearLabel { id, name })
420}
421
422/// Prefer configured team, else the only accessible team, else error.
423pub async fn resolve_team_id_auto(
424    api_key: &str,
425    preferred: Option<&str>,
426) -> Result<String, String> {
427    if let Some(preferred) = preferred.map(str::trim).filter(|s| !s.is_empty()) {
428        return resolve_team_id(api_key, preferred).await;
429    }
430    let teams = list_teams(api_key).await?;
431    if teams.len() == 1 {
432        return Ok(teams[0].id.clone());
433    }
434    if teams.is_empty() {
435        return Err("No Linear teams available for this API key.".into());
436    }
437    Err(
438        "Multiple Linear teams found. Pass `--team KEY` or set `linear.default_team_key` in `.xbp/xbp.yaml`."
439            .into(),
440    )
441}
442
443pub async fn list_users(api_key: &str) -> Result<Vec<LinearUser>, String> {
444    let mut users = Vec::new();
445    let mut after: Option<String> = None;
446    loop {
447        let body = linear_graphql_request(
448            api_key,
449            &json!({
450                "query": r#"
451                    query XbpLinearUsers($after: String) {
452                      users(first: 50, after: $after, includeDisabled: false) {
453                        nodes { id name email displayName }
454                        pageInfo { hasNextPage endCursor }
455                      }
456                    }
457                "#,
458                "variables": { "after": after }
459            }),
460        )
461        .await?;
462        let page: UsersPage = extract_data(body, "users")?;
463        users.extend(page.nodes.into_iter().map(|n| LinearUser {
464            id: n.id,
465            name: n.name,
466            email: n.email,
467            display_name: n.display_name,
468        }));
469        if !page.page_info.has_next_page {
470            break;
471        }
472        after = page.page_info.end_cursor;
473        if after.is_none() {
474            break;
475        }
476    }
477    Ok(users)
478}
479
480pub async fn resolve_assignee_id(api_key: &str, assignee: &str) -> Result<Option<String>, String> {
481    let assignee = assignee.trim();
482    if assignee.is_empty() || assignee.eq_ignore_ascii_case("none") {
483        return Ok(None);
484    }
485    if assignee.eq_ignore_ascii_case("me") {
486        return Ok(Some(viewer_id(api_key).await?));
487    }
488    if assignee.len() >= 32 && assignee.contains('-') {
489        return Ok(Some(assignee.to_string()));
490    }
491    let users = list_users(api_key).await?;
492    let lower = assignee.to_ascii_lowercase();
493    if let Some(found) = users.iter().find(|u| {
494        u.id == assignee
495            || u.name.eq_ignore_ascii_case(assignee)
496            || u.email
497                .as_deref()
498                .is_some_and(|e| e.eq_ignore_ascii_case(assignee))
499            || u.display_name
500                .as_deref()
501                .is_some_and(|d| d.eq_ignore_ascii_case(assignee))
502            || u.name.to_ascii_lowercase() == lower
503    }) {
504        return Ok(Some(found.id.clone()));
505    }
506    Err(format!("No Linear user matching `{assignee}`."))
507}
508
509async fn viewer_id(api_key: &str) -> Result<String, String> {
510    let body = linear_graphql_request(
511        api_key,
512        &json!({
513            "query": r#"query XbpLinearViewer { viewer { id } }"#
514        }),
515    )
516    .await?;
517    linear_graphql_errors(&body)?;
518    body.get("data")
519        .and_then(|d| d.get("viewer"))
520        .and_then(|v| v.get("id"))
521        .and_then(JsonValue::as_str)
522        .map(str::to_string)
523        .ok_or_else(|| "Linear viewer query returned no id.".to_string())
524}
525
526fn extract_data<T: for<'de> Deserialize<'de>>(body: JsonValue, field: &str) -> Result<T, String> {
527    linear_graphql_errors(&body)?;
528    let value = body
529        .get("data")
530        .and_then(|d| d.get(field))
531        .cloned()
532        .ok_or_else(|| format!("Linear query returned no `{field}` data."))?;
533    serde_json::from_value(value).map_err(|e| format!("Failed to decode Linear `{field}`: {e}"))
534}
535
536