Skip to main content

cliban_sync/linear/
client.rs

1//! The Linear GraphQL transport.
2//!
3//! The whole remote surface is one method — post a document and some variables,
4//! get `data` back — and every typed operation in [`super::ops`] is written
5//! against that trait rather than against reqwest. Tests then substitute a fake
6//! that returns canned JSON, so the response-parsing and mapping logic is
7//! exercised without a network, a token, or a mock HTTP server.
8
9use std::time::Duration;
10
11use serde_json::Value;
12
13use crate::error::{Error, Result};
14
15/// Linear's production endpoint.
16pub const ENDPOINT: &str = "https://api.linear.app/graphql";
17
18/// Overrides [`ENDPOINT`]. Exists for pointing integration tests at a local
19/// stub; there is no reason to set it otherwise.
20pub const ENDPOINT_ENV: &str = "CLIBAN_LINEAR_ENDPOINT";
21
22/// A GraphQL endpoint that can answer a document. One method, so a test double
23/// is a few lines rather than a mock framework.
24#[allow(async_fn_in_trait)]
25pub trait GraphQl {
26    /// Execute `doc` with `vars` and return the `data` object.
27    async fn query(&self, doc: &str, vars: Value) -> Result<Value>;
28}
29
30pub struct Client {
31    http: reqwest::Client,
32    token: String,
33    endpoint: String,
34}
35
36impl Client {
37    /// Build a client from a token. Reads [`ENDPOINT_ENV`] for the endpoint.
38    pub fn new(token: String) -> Result<Self> {
39        let http = reqwest::Client::builder()
40            .user_agent(concat!("cliban/", env!("CARGO_PKG_VERSION")))
41            .timeout(Duration::from_secs(30))
42            .build()?;
43        let endpoint = std::env::var(ENDPOINT_ENV)
44            .ok()
45            .filter(|v| !v.is_empty())
46            .unwrap_or_else(|| ENDPOINT.to_string());
47        Ok(Self {
48            http,
49            token,
50            endpoint,
51        })
52    }
53
54    /// Build a client reading the token from `$LINEAR_API_KEY`.
55    pub fn from_env() -> Result<Self> {
56        Self::new(crate::config::token()?)
57    }
58
59    async fn post(&self, body: &Value) -> Result<reqwest::Response> {
60        Ok(self
61            .http
62            .post(&self.endpoint)
63            // Linear personal API keys go in Authorization *without* a
64            // "Bearer " prefix; OAuth tokens use the prefix. Sending the key
65            // bare is what works for the keys users mint in settings.
66            .header(reqwest::header::AUTHORIZATION, &self.token)
67            .json(body)
68            .send()
69            .await?)
70    }
71}
72
73impl GraphQl for Client {
74    async fn query(&self, doc: &str, vars: Value) -> Result<Value> {
75        let body = serde_json::json!({ "query": doc, "variables": vars });
76
77        let mut response = self.post(&body).await?;
78
79        // One retry on rate limiting. Linear's limits are generous and these
80        // are single-issue commands, so a second 429 means something is wrong
81        // that waiting longer will not fix.
82        if response.status().as_u16() == 429 {
83            let wait = retry_after(&response).unwrap_or(Duration::from_secs(2));
84            tokio::time::sleep(wait).await;
85            response = self.post(&body).await?;
86        }
87
88        let status = response.status().as_u16();
89        if matches!(status, 401 | 403) {
90            return Err(Error::Unauthorized(status));
91        }
92
93        let text = response.text().await?;
94        if !(200..300).contains(&status) {
95            return Err(Error::Http(format!("HTTP {status}: {}", truncate(&text))));
96        }
97
98        let parsed: Value = serde_json::from_str(&text)
99            .map_err(|e| Error::Unexpected(format!("{e}: {}", truncate(&text))))?;
100        extract_data(parsed)
101    }
102}
103
104/// Pull `data` out of a GraphQL envelope, turning `errors` into [`Error::Api`].
105///
106/// Linear can return both `data` and `errors` for a partially-successful query.
107/// We treat any error as fatal: a partial result would mean importing an issue
108/// with fields silently missing, which is worse than failing.
109fn extract_data(mut envelope: Value) -> Result<Value> {
110    if let Some(errors) = envelope.get("errors").and_then(Value::as_array) {
111        if !errors.is_empty() {
112            let messages = errors
113                .iter()
114                .map(|e| {
115                    e.get("message")
116                        .and_then(Value::as_str)
117                        .unwrap_or("(no message)")
118                        .to_string()
119                })
120                .collect();
121            return Err(Error::Api(messages));
122        }
123    }
124    match envelope.get_mut("data") {
125        Some(data) => Ok(data.take()),
126        None => Err(Error::Unexpected("response had no `data` field".into())),
127    }
128}
129
130fn retry_after(response: &reqwest::Response) -> Option<Duration> {
131    response
132        .headers()
133        .get(reqwest::header::RETRY_AFTER)?
134        .to_str()
135        .ok()?
136        .trim()
137        .parse::<u64>()
138        .ok()
139        // Cap it: a huge Retry-After should surface as an error the user sees,
140        // not a CLI that appears to hang.
141        .map(|secs| Duration::from_secs(secs.min(30)))
142}
143
144/// Keep error messages readable when the remote returns an HTML error page.
145fn truncate(s: &str) -> String {
146    const MAX: usize = 300;
147    if s.len() <= MAX {
148        return s.to_string();
149    }
150    let cut = s
151        .char_indices()
152        .map(|(i, _)| i)
153        .take_while(|i| *i <= MAX)
154        .last()
155        .unwrap_or(0);
156    format!("{}…", &s[..cut])
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162
163    #[test]
164    fn extract_data_unwraps_the_envelope() {
165        let env = serde_json::json!({"data": {"issue": {"id": "x"}}});
166        let data = extract_data(env).unwrap();
167        assert_eq!(data["issue"]["id"], "x");
168    }
169
170    #[test]
171    fn graphql_errors_beat_partial_data() {
172        // Linear returns both on a partial failure; importing the partial
173        // result would write an issue with fields silently missing.
174        let env = serde_json::json!({
175            "data": {"issue": null},
176            "errors": [{"message": "Entity not found"}, {"message": "and another"}]
177        });
178        let err = extract_data(env).unwrap_err();
179        let msg = err.to_string();
180        assert!(msg.contains("Entity not found"), "{msg}");
181        assert!(msg.contains("and another"), "{msg}");
182    }
183
184    #[test]
185    fn an_empty_errors_array_is_not_an_error() {
186        let env = serde_json::json!({"data": {"ok": true}, "errors": []});
187        assert!(extract_data(env).is_ok());
188    }
189
190    #[test]
191    fn a_missing_data_field_is_reported_as_unexpected() {
192        let err = extract_data(serde_json::json!({"nonsense": 1})).unwrap_err();
193        assert!(err.to_string().contains("no `data` field"));
194    }
195
196    #[test]
197    fn errors_without_a_message_still_produce_output() {
198        let env = serde_json::json!({"errors": [{"code": 500}]});
199        let err = extract_data(env).unwrap_err();
200        assert!(err.to_string().contains("no message"));
201    }
202
203    #[test]
204    fn truncate_keeps_long_html_error_pages_readable() {
205        let long = "x".repeat(1000);
206        let out = truncate(&long);
207        assert!(out.len() < 400);
208        assert!(out.ends_with('…'));
209        assert_eq!(truncate("short"), "short");
210    }
211
212    #[test]
213    fn truncate_does_not_split_a_multibyte_character() {
214        let s = "é".repeat(400);
215        let out = truncate(&s);
216        assert!(out.ends_with('…'));
217    }
218}