Skip to main content

linear_tools/
http.rs

1use agentic_config::types::LinearServiceConfig;
2use anyhow::Result;
3use anyhow::anyhow;
4use cynic::http::ReqwestExt;
5use reqwest::Client;
6use std::time::Duration;
7
8pub struct LinearClient {
9    client: Client,
10    url: String,
11    api_key: String,
12}
13
14/// Centralized GraphQL error extraction - fails fast on any errors
15pub fn extract_data<Q>(resp: cynic::GraphQlResponse<Q>) -> Result<Q> {
16    if let Some(errors) = resp.errors
17        && !errors.is_empty()
18    {
19        let mut parts = Vec::new();
20        for e in errors {
21            let path = e.path.unwrap_or_default();
22            let path_str = if path.is_empty() {
23                String::new()
24            } else {
25                let p = path
26                    .into_iter()
27                    .map(|v| match v {
28                        cynic::GraphQlErrorPathSegment::Field(f) => f,
29                        cynic::GraphQlErrorPathSegment::Index(i) => i.to_string(),
30                    })
31                    .collect::<Vec<_>>()
32                    .join(".");
33                format!(" (path: {p})")
34            };
35            parts.push(format!("{}{}", e.message, path_str));
36        }
37        return Err(anyhow!(
38            "GraphQL errors from Linear:\n- {}",
39            parts.join("\n- ")
40        ));
41    }
42
43    match resp.data {
44        Some(data) => Ok(data),
45        None => Err(anyhow!("No data returned from Linear")),
46    }
47}
48
49impl LinearClient {
50    pub fn new(api_key: Option<String>, config: &LinearServiceConfig) -> Result<Self> {
51        let api_key = match api_key.or_else(|| std::env::var("LINEAR_API_KEY").ok()) {
52            Some(k) if !k.is_empty() => k,
53            _ => return Err(anyhow!("LINEAR_API_KEY environment variable is not set")),
54        };
55
56        let url = std::env::var("LINEAR_GRAPHQL_URL")
57            .ok()
58            .filter(|u| !u.is_empty())
59            .unwrap_or_else(|| config.base_url.clone());
60
61        let mut builder = Client::builder().user_agent("linear-tools/0.1.0");
62        if config.connect_timeout_secs != 0 {
63            builder = builder.connect_timeout(Duration::from_secs(config.connect_timeout_secs));
64        }
65        if config.request_timeout_secs != 0 {
66            builder = builder.timeout(Duration::from_secs(config.request_timeout_secs));
67        }
68        let client = builder.build()?;
69
70        Ok(Self {
71            client,
72            url,
73            api_key,
74        })
75    }
76
77    pub async fn run<Q, V>(&self, op: cynic::Operation<Q, V>) -> Result<cynic::GraphQlResponse<Q>>
78    where
79        Q: serde::de::DeserializeOwned + 'static,
80        V: serde::Serialize,
81    {
82        let mut req = self
83            .client
84            .post(&self.url)
85            .header("Content-Type", "application/json");
86
87        // Auto-detect auth header type:
88        // - Personal API key: "lin_api_*" => raw Authorization header
89        // - OAuth2 token: anything else => Bearer token
90        if self.api_key.starts_with("lin_api_") {
91            req = req.header("Authorization", &self.api_key);
92        } else {
93            req = req.bearer_auth(&self.api_key);
94        }
95
96        let result = req.run_graphql(op).await;
97        result.map_err(|e| anyhow!(e))
98    }
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104    use serial_test::serial;
105
106    struct EnvGuard(&'static str);
107
108    impl Drop for EnvGuard {
109        fn drop(&mut self) {
110            // SAFETY: These tests run under #[serial], so no concurrent env access occurs.
111            unsafe {
112                std::env::remove_var(self.0);
113            }
114        }
115    }
116
117    #[test]
118    #[serial]
119    fn new_uses_configured_base_url_and_zero_timeouts() {
120        // SAFETY: This test runs under #[serial], so no concurrent env access occurs.
121        unsafe {
122            std::env::remove_var("LINEAR_API_KEY");
123            std::env::remove_var("LINEAR_GRAPHQL_URL");
124        }
125        let _g1 = EnvGuard("LINEAR_API_KEY");
126        let _g2 = EnvGuard("LINEAR_GRAPHQL_URL");
127
128        let config = LinearServiceConfig {
129            base_url: "https://linear.example/graphql".into(),
130            connect_timeout_secs: 0,
131            request_timeout_secs: 0,
132        };
133
134        let client = LinearClient::new(Some("token".into()), &config).unwrap();
135        assert_eq!(client.url, "https://linear.example/graphql");
136    }
137
138    #[test]
139    #[serial]
140    fn new_preserves_legacy_env_override_for_url() {
141        // SAFETY: This test runs under #[serial], so no concurrent env access occurs.
142        unsafe {
143            std::env::set_var("LINEAR_GRAPHQL_URL", "https://env.example/graphql");
144        }
145        let _guard = EnvGuard("LINEAR_GRAPHQL_URL");
146
147        let config = LinearServiceConfig::default();
148        let client = LinearClient::new(Some("token".into()), &config).unwrap();
149        assert_eq!(client.url, "https://env.example/graphql");
150    }
151}