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
14pub 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 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 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 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 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}