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}