Skip to main content

xbp_cli/commands/
linear.rs

1use async_trait::async_trait;
2use serde::Deserialize;
3use serde_json::{json, Value as JsonValue};
4
5const LINEAR_GRAPHQL_ENDPOINT: &str = "https://api.linear.app/graphql";
6const LINEAR_USER_AGENT: &str = "xbp-cli/1.0";
7
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub(crate) struct LinearInitiativeSummary {
10    pub(crate) id: String,
11    pub(crate) name: String,
12    pub(crate) status: Option<String>,
13    pub(crate) health: Option<String>,
14    pub(crate) archived_at: Option<String>,
15    pub(crate) target_date: Option<String>,
16    pub(crate) owner_name: Option<String>,
17}
18
19#[derive(Debug, Clone, Deserialize)]
20pub(crate) struct GraphqlTypeRef {
21    pub(crate) name: Option<String>,
22    #[serde(rename = "ofType")]
23    pub(crate) of_type: Option<Box<GraphqlTypeRef>>,
24}
25
26#[derive(Debug, Clone, Deserialize)]
27pub(crate) struct GraphqlFieldArg {
28    pub(crate) name: String,
29    #[serde(rename = "type")]
30    pub(crate) type_ref: GraphqlTypeRef,
31}
32
33#[derive(Debug, Clone, Deserialize)]
34pub(crate) struct GraphqlField {
35    pub(crate) name: String,
36    pub(crate) args: Vec<GraphqlFieldArg>,
37}
38
39#[derive(Debug, Deserialize)]
40pub(crate) struct GraphqlTypeData {
41    pub(crate) fields: Option<Vec<GraphqlField>>,
42    #[serde(rename = "inputFields")]
43    pub(crate) input_fields: Option<Vec<GraphqlFieldArg>>,
44    #[serde(rename = "enumValues")]
45    pub(crate) enum_values: Option<Vec<GraphqlEnumValue>>,
46}
47
48#[derive(Debug, Deserialize)]
49pub(crate) struct GraphqlEnumValue {
50    pub(crate) name: String,
51}
52
53#[derive(Debug, Clone, Deserialize)]
54struct LinearInitiativesPage {
55    nodes: Vec<LinearInitiativeNode>,
56    #[serde(rename = "pageInfo")]
57    page_info: LinearPageInfo,
58}
59
60#[derive(Debug, Clone, Deserialize)]
61struct LinearPageInfo {
62    #[serde(rename = "hasNextPage")]
63    has_next_page: bool,
64    #[serde(rename = "endCursor")]
65    end_cursor: Option<String>,
66}
67
68#[derive(Debug, Clone, Deserialize)]
69struct LinearInitiativeNode {
70    id: String,
71    name: String,
72    #[serde(default)]
73    status: Option<String>,
74    #[serde(default)]
75    health: Option<String>,
76    #[serde(default, rename = "archivedAt")]
77    archived_at: Option<String>,
78    #[serde(default, rename = "targetDate")]
79    target_date: Option<String>,
80    #[serde(default)]
81    owner: Option<LinearOwner>,
82}
83
84#[derive(Debug, Clone, Deserialize)]
85struct LinearOwner {
86    #[serde(default)]
87    name: Option<String>,
88}
89
90#[async_trait]
91trait LinearGraphqlExecutor {
92    async fn execute(&mut self, body: &JsonValue) -> Result<JsonValue, String>;
93}
94
95struct ReqwestLinearGraphqlExecutor<'a> {
96    api_key: &'a str,
97}
98
99#[async_trait]
100impl LinearGraphqlExecutor for ReqwestLinearGraphqlExecutor<'_> {
101    async fn execute(&mut self, body: &JsonValue) -> Result<JsonValue, String> {
102        linear_graphql_request(self.api_key, body).await
103    }
104}
105
106pub(crate) async fn fetch_available_initiatives(
107    api_key: &str,
108) -> Result<Vec<LinearInitiativeSummary>, String> {
109    let mut executor = ReqwestLinearGraphqlExecutor { api_key };
110    fetch_available_initiatives_with(&mut executor).await
111}
112
113pub(crate) async fn fetch_graphql_type(
114    api_key: &str,
115    type_name: &str,
116) -> Result<GraphqlTypeData, String> {
117    let body = linear_graphql_request(
118        api_key,
119        &json!({
120            "query": r#"
121                query XbpLinearType($name: String!) {
122                  __type(name: $name) {
123                    fields {
124                      name
125                      args {
126                        name
127                        type {
128                          name
129                          ofType {
130                            name
131                            ofType {
132                              name
133                              ofType {
134                                name
135                                ofType {
136                                  name
137                                }
138                              }
139                            }
140                          }
141                        }
142                      }
143                    }
144                    inputFields {
145                      name
146                      type {
147                        name
148                        ofType {
149                          name
150                          ofType {
151                            name
152                            ofType {
153                              name
154                              ofType {
155                                name
156                              }
157                            }
158                          }
159                        }
160                      }
161                    }
162                    enumValues {
163                      name
164                    }
165                  }
166                }
167            "#,
168            "variables": {
169                "name": type_name
170            }
171        }),
172    )
173    .await?;
174
175    if let Some(errors) = body.get("errors").and_then(JsonValue::as_array) {
176        let message = errors
177            .first()
178            .and_then(|error| error.get("message"))
179            .and_then(JsonValue::as_str)
180            .unwrap_or("unknown Linear API error");
181        return Err(message.to_string());
182    }
183
184    let type_value = body
185        .get("data")
186        .and_then(|data| data.get("__type"))
187        .cloned()
188        .ok_or_else(|| {
189            format!(
190                "Linear schema type lookup for `{}` returned no data.",
191                type_name
192            )
193        })?;
194    serde_json::from_value(type_value)
195        .map_err(|e| format!("Failed to decode Linear schema for `{}`: {}", type_name, e))
196}
197
198pub(crate) async fn linear_graphql_request(
199    api_key: &str,
200    body: &JsonValue,
201) -> Result<JsonValue, String> {
202    let response = reqwest::Client::new()
203        .post(LINEAR_GRAPHQL_ENDPOINT)
204        .header("Authorization", api_key.trim())
205        .header("Content-Type", "application/json")
206        .header("User-Agent", LINEAR_USER_AGENT)
207        .json(body)
208        .send()
209        .await
210        .map_err(|e| format!("Linear API request failed: {}", e))?;
211    let status = response.status();
212    let body: JsonValue = response
213        .json()
214        .await
215        .map_err(|e| format!("Failed to decode Linear API response: {}", e))?;
216    if !status.is_success() {
217        let detail = body
218            .get("errors")
219            .and_then(JsonValue::as_array)
220            .and_then(|errors| errors.first())
221            .and_then(|error| error.get("message"))
222            .and_then(JsonValue::as_str)
223            .unwrap_or("unknown Linear API error");
224        return Err(format!("Linear API returned {}: {}", status, detail));
225    }
226
227    Ok(body)
228}
229
230pub(crate) fn named_type_name(type_ref: &GraphqlTypeRef) -> Option<String> {
231    let mut current = Some(type_ref);
232    while let Some(value) = current {
233        if let Some(name) = &value.name {
234            return Some(name.clone());
235        }
236        current = value.of_type.as_deref();
237    }
238    None
239}
240
241async fn fetch_available_initiatives_with<E>(
242    executor: &mut E,
243) -> Result<Vec<LinearInitiativeSummary>, String>
244where
245    E: LinearGraphqlExecutor + Send,
246{
247    let mut initiatives = Vec::new();
248    let mut after: Option<String> = None;
249
250    loop {
251        let page = fetch_initiatives_page(executor, after.as_deref()).await?;
252        initiatives.extend(
253            page.nodes
254                .into_iter()
255                .filter(|initiative| initiative.archived_at.is_none())
256                .map(Into::into),
257        );
258
259        if !page.page_info.has_next_page {
260            break;
261        }
262
263        after = page.page_info.end_cursor;
264        if after.is_none() {
265            return Err(
266                "Linear initiatives pagination returned `hasNextPage=true` without an end cursor."
267                    .to_string(),
268            );
269        }
270    }
271
272    Ok(initiatives)
273}
274
275async fn fetch_initiatives_page<E>(
276    executor: &mut E,
277    after: Option<&str>,
278) -> Result<LinearInitiativesPage, String>
279where
280    E: LinearGraphqlExecutor + Send,
281{
282    let body = executor
283        .execute(&json!({
284            "query": r#"
285                query XbpLinearInitiatives($after: String) {
286                  initiatives(first: 50, after: $after, includeArchived: false, orderBy: updatedAt) {
287                    nodes {
288                      id
289                      name
290                      status
291                      health
292                      archivedAt
293                      targetDate
294                      owner {
295                        name
296                      }
297                    }
298                    pageInfo {
299                      hasNextPage
300                      endCursor
301                    }
302                  }
303                }
304            "#,
305            "variables": {
306                "after": after
307            }
308        }))
309        .await?;
310
311    parse_linear_initiatives_page(body)
312}
313
314fn parse_linear_initiatives_page(body: JsonValue) -> Result<LinearInitiativesPage, String> {
315    if let Some(errors) = body.get("errors").and_then(JsonValue::as_array) {
316        let message = errors
317            .first()
318            .and_then(|error| error.get("message"))
319            .and_then(JsonValue::as_str)
320            .unwrap_or("unknown Linear API error");
321        return Err(message.to_string());
322    }
323
324    let initiatives = body
325        .get("data")
326        .and_then(|data| data.get("initiatives"))
327        .cloned()
328        .ok_or_else(|| "Linear initiatives query returned no data.".to_string())?;
329
330    serde_json::from_value(initiatives)
331        .map_err(|e| format!("Failed to decode Linear initiatives response: {}", e))
332}
333
334impl From<LinearInitiativeNode> for LinearInitiativeSummary {
335    fn from(value: LinearInitiativeNode) -> Self {
336        Self {
337            id: value.id,
338            name: value.name,
339            status: value.status,
340            health: value.health,
341            archived_at: value.archived_at,
342            target_date: value.target_date,
343            owner_name: value.owner.and_then(|owner| owner.name),
344        }
345    }
346}
347
348#[cfg(test)]
349mod tests {
350    use super::{fetch_available_initiatives_with, LinearGraphqlExecutor, LinearInitiativeSummary};
351    use async_trait::async_trait;
352    use serde_json::{json, Value as JsonValue};
353    use std::collections::VecDeque;
354
355    struct MockExecutor {
356        responses: VecDeque<Result<JsonValue, String>>,
357        requests: Vec<JsonValue>,
358    }
359
360    #[async_trait]
361    impl LinearGraphqlExecutor for MockExecutor {
362        async fn execute(&mut self, body: &JsonValue) -> Result<JsonValue, String> {
363            self.requests.push(body.clone());
364            self.responses
365                .pop_front()
366                .expect("mock response should exist")
367        }
368    }
369
370    #[tokio::test]
371    async fn paginates_available_initiatives_and_skips_archived_rows() {
372        let mut executor = MockExecutor {
373            responses: VecDeque::from(vec![
374                Ok(json!({
375                    "data": {
376                        "initiatives": {
377                            "nodes": [
378                                {
379                                    "id": "init-1",
380                                    "name": "First",
381                                    "status": "Active",
382                                    "health": "onTrack",
383                                    "archivedAt": null,
384                                    "targetDate": "2026-06-30",
385                                    "owner": { "name": "floris" }
386                                },
387                                {
388                                    "id": "init-archived",
389                                    "name": "Archived",
390                                    "status": "Completed",
391                                    "health": "offTrack",
392                                    "archivedAt": "2026-05-01T00:00:00.000Z",
393                                    "targetDate": null,
394                                    "owner": null
395                                }
396                            ],
397                            "pageInfo": {
398                                "hasNextPage": true,
399                                "endCursor": "cursor-1"
400                            }
401                        }
402                    }
403                })),
404                Ok(json!({
405                    "data": {
406                        "initiatives": {
407                            "nodes": [
408                                {
409                                    "id": "init-2",
410                                    "name": "Second",
411                                    "status": "Planned",
412                                    "health": null,
413                                    "archivedAt": null,
414                                    "targetDate": null,
415                                    "owner": { "name": "suits" }
416                                }
417                            ],
418                            "pageInfo": {
419                                "hasNextPage": false,
420                                "endCursor": "cursor-2"
421                            }
422                        }
423                    }
424                })),
425            ]),
426            requests: Vec::new(),
427        };
428
429        let initiatives = fetch_available_initiatives_with(&mut executor)
430            .await
431            .expect("initiatives");
432
433        assert_eq!(
434            initiatives,
435            vec![
436                LinearInitiativeSummary {
437                    id: "init-1".to_string(),
438                    name: "First".to_string(),
439                    status: Some("Active".to_string()),
440                    health: Some("onTrack".to_string()),
441                    archived_at: None,
442                    target_date: Some("2026-06-30".to_string()),
443                    owner_name: Some("floris".to_string()),
444                },
445                LinearInitiativeSummary {
446                    id: "init-2".to_string(),
447                    name: "Second".to_string(),
448                    status: Some("Planned".to_string()),
449                    health: None,
450                    archived_at: None,
451                    target_date: None,
452                    owner_name: Some("suits".to_string()),
453                },
454            ]
455        );
456        assert_eq!(executor.requests.len(), 2);
457        assert_eq!(executor.requests[0]["variables"]["after"], JsonValue::Null);
458        assert_eq!(
459            executor.requests[1]["variables"]["after"],
460            JsonValue::String("cursor-1".to_string())
461        );
462    }
463
464    #[tokio::test]
465    async fn returns_empty_list_when_workspace_has_no_initiatives() {
466        let mut executor = MockExecutor {
467            responses: VecDeque::from(vec![Ok(json!({
468                "data": {
469                    "initiatives": {
470                        "nodes": [],
471                        "pageInfo": {
472                            "hasNextPage": false,
473                            "endCursor": null
474                        }
475                    }
476                }
477            }))]),
478            requests: Vec::new(),
479        };
480
481        let initiatives = fetch_available_initiatives_with(&mut executor)
482            .await
483            .expect("initiatives");
484        assert!(initiatives.is_empty());
485    }
486
487    #[tokio::test]
488    async fn surfaces_graphql_errors_from_initiative_query() {
489        let mut executor = MockExecutor {
490            responses: VecDeque::from(vec![Ok(json!({
491                "errors": [
492                    {
493                        "message": "Linear said no"
494                    }
495                ]
496            }))]),
497            requests: Vec::new(),
498        };
499
500        let err = fetch_available_initiatives_with(&mut executor)
501            .await
502            .expect_err("should fail");
503        assert_eq!(err, "Linear said no");
504    }
505}