pub mod cache;
use crate::error::LinearError;
use cache::Cache;
use reqwest::Client;
use std::env;
use std::path::PathBuf;
use std::time::Duration;
const API_URL: &str = "https://api.linear.app/graphql";
const MAX_RETRIES: u32 = 3;
const BACKOFF_BASE_MS: u64 = 1000;
pub struct LinearClient {
http: Client,
api_key: String,
debug: bool,
cache: Cache,
}
impl LinearClient {
pub fn new(api_key: Option<String>, debug: bool) -> Result<Self, LinearError> {
let api_key = match api_key {
Some(key) => key,
None => Self::resolve_api_key()?,
};
let http = Client::builder()
.timeout(Duration::from_secs(30))
.https_only(true)
.build()
.map_err(LinearError::Request)?;
Ok(Self {
http,
api_key,
debug,
cache: Cache::new(),
})
}
fn resolve_api_key() -> Result<String, LinearError> {
if let Ok(key) = env::var("LINEAR_API_KEY")
&& !key.is_empty()
{
return Ok(key);
}
if let Some(key) = crate::config::get_api_key() {
return Ok(key);
}
for filename in &[".env", ".env.local"] {
for dir in Self::search_dirs() {
let path = dir.join(filename);
if let Ok(contents) = std::fs::read_to_string(&path) {
for line in contents.lines() {
let line = line.trim();
if let Some(val) = line.strip_prefix("LINEAR_API_KEY=") {
let val = val.trim().trim_matches('"').trim_matches('\'');
if !val.is_empty() {
return Ok(val.to_string());
}
}
}
}
}
}
Err(LinearError::NoApiKey)
}
fn search_dirs() -> Vec<PathBuf> {
let mut dirs = Vec::new();
if let Ok(cwd) = env::current_dir() {
dirs.push(cwd);
}
dirs
}
pub async fn query_raw(
&self,
query: &str,
variables: Option<serde_json::Value>,
) -> Result<serde_json::Value, LinearError> {
if self.debug {
eprintln!("--- GraphQL Query ---\n{query}");
if let Some(ref vars) = variables {
eprintln!(
"--- Variables ---\n{}",
serde_json::to_string_pretty(vars).unwrap_or_default()
);
}
}
let mut body = serde_json::json!({"query": query});
if let Some(vars) = &variables {
body["variables"] = vars.clone();
}
let mut last_err = None;
for attempt in 0..=MAX_RETRIES {
let response = self
.http
.post(API_URL)
.header("Content-Type", "application/json")
.header("Authorization", &self.api_key)
.json(&body)
.send()
.await
.map_err(LinearError::Request)?;
let status = response.status();
if status == reqwest::StatusCode::TOO_MANY_REQUESTS && attempt < MAX_RETRIES {
let wait = Duration::from_millis(BACKOFF_BASE_MS * 2u64.pow(attempt));
tokio::time::sleep(wait).await;
continue;
}
let response_text = response.text().await.map_err(LinearError::Request)?;
if self.debug {
eprintln!("--- Response ---\n{response_text}");
}
if !status.is_success() {
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&response_text)
&& let Some(errors) = parsed.get("errors").and_then(|e| e.as_array())
{
let msgs: Vec<&str> = errors
.iter()
.filter_map(|e| e.get("message").and_then(|m| m.as_str()))
.collect();
if !msgs.is_empty() {
return Err(LinearError::GraphQL(msgs.join("; ")));
}
}
let truncated = if response_text.len() > 200 {
format!("{}... (truncated)", &response_text[..200])
} else {
response_text
};
last_err = Some(LinearError::Http {
status: status.as_u16(),
body: truncated,
});
continue;
}
let gql_response: serde_json::Value =
serde_json::from_str(&response_text).map_err(LinearError::Json)?;
if let Some(errors) = gql_response.get("errors").and_then(|e| e.as_array())
&& !errors.is_empty()
{
let msgs: Vec<&str> = errors
.iter()
.filter_map(|e| e.get("message").and_then(|m| m.as_str()))
.collect();
return Err(LinearError::GraphQL(msgs.join("; ")));
}
return Ok(gql_response);
}
Err(last_err.unwrap_or(LinearError::GraphQL("Max retries exceeded".into())))
}
pub async fn get_teams(&self) -> Result<Vec<cache::CachedTeam>, LinearError> {
if let Some(teams) = self.cache.teams.get() {
return Ok(teams.clone());
}
let result = self
.query_raw("query { teams { nodes { id name key } } }", None)
.await?;
let nodes = result
.pointer("/data/teams/nodes")
.ok_or_else(|| LinearError::GraphQL("No teams data".into()))?;
let teams: Vec<cache::CachedTeam> = serde_json::from_value(nodes.clone())?;
let _ = self.cache.teams.set(teams.clone());
Ok(teams)
}
pub async fn get_team_id(&self, key_or_name: &str) -> Result<String, LinearError> {
let teams = self.get_teams().await?;
Ok(cache::find_team(&teams, key_or_name)?.id.clone())
}
pub async fn get_users(&self) -> Result<Vec<cache::CachedUser>, LinearError> {
if let Some(users) = self.cache.users.get() {
return Ok(users.clone());
}
let result = self
.query_raw(
"query { users(first: 250) { nodes { id name displayName email active } } }",
None,
)
.await?;
let nodes = result
.pointer("/data/users/nodes")
.ok_or_else(|| LinearError::GraphQL("No users data".into()))?;
let users: Vec<cache::CachedUser> = serde_json::from_value(nodes.clone())?;
let _ = self.cache.users.set(users.clone());
Ok(users)
}
pub async fn get_user_id(&self, name: &str) -> Result<String, LinearError> {
let users = self.get_users().await?;
Ok(cache::find_user(&users, name)?.id.clone())
}
pub async fn get_project_id(&self, name: &str) -> Result<String, LinearError> {
let projects = if let Some(p) = self.cache.projects.get() {
p.clone()
} else {
let result = self
.query_raw("query { projects(first: 250) { nodes { id name } } }", None)
.await?;
let nodes = result
.pointer("/data/projects/nodes")
.ok_or_else(|| LinearError::GraphQL("No projects data".into()))?;
let projects: Vec<cache::CachedProject> = serde_json::from_value(nodes.clone())?;
let _ = self.cache.projects.set(projects.clone());
projects
};
Ok(cache::find_project(&projects, name)?.id.clone())
}
pub async fn get_state_id(
&self,
team_key: &str,
state_name: &str,
) -> Result<String, LinearError> {
if !self.cache.states.contains_key(team_key) {
let team_id = self.get_team_id(team_key).await?;
let result = self.query_raw(
r#"query($teamId: ID!) { workflowStates(filter: { team: { id: { eq: $teamId } } }) { nodes { id name type } } }"#,
Some(serde_json::json!({"teamId": team_id})),
).await?;
let nodes = result
.pointer("/data/workflowStates/nodes")
.ok_or_else(|| LinearError::GraphQL("No states data".into()))?;
let states: Vec<cache::CachedState> = serde_json::from_value(nodes.clone())?;
self.cache.states.insert(team_key.to_string(), states);
}
let entry = self.cache.states.get(team_key).unwrap();
Ok(cache::find_state(entry.value(), state_name)?.id.clone())
}
pub async fn get_label_ids(
&self,
names: &[&str],
team_key: Option<&str>,
) -> Result<Vec<String>, LinearError> {
let cache_key = team_key.unwrap_or("__workspace__");
if !self.cache.labels.contains_key(cache_key) {
if let Some(tk) = team_key {
let team_id = self.get_team_id(tk).await?;
let team_query = r#"query($teamId: ID!) { issueLabels(filter: { team: { id: { eq: $teamId } } }) { nodes { id name } } }"#;
let team_result = self
.query_raw(team_query, Some(serde_json::json!({"teamId": team_id})))
.await?;
let team_nodes = team_result
.pointer("/data/issueLabels/nodes")
.ok_or_else(|| LinearError::GraphQL("No labels data".into()))?;
let mut labels: Vec<cache::CachedLabel> =
serde_json::from_value(team_nodes.clone())?;
let ws_query = r#"query { issueLabels(filter: { team: { null: true } }) { nodes { id name } } }"#;
let ws_result = self.query_raw(ws_query, None).await?;
if let Some(ws_nodes) = ws_result.pointer("/data/issueLabels/nodes") {
let ws_labels: Vec<cache::CachedLabel> =
serde_json::from_value(ws_nodes.clone())?;
for wl in ws_labels {
if !labels.iter().any(|l| l.id == wl.id) {
labels.push(wl);
}
}
}
self.cache.labels.insert(cache_key.to_string(), labels);
} else {
let result = self
.query_raw("query { issueLabels { nodes { id name } } }", None)
.await?;
let nodes = result
.pointer("/data/issueLabels/nodes")
.ok_or_else(|| LinearError::GraphQL("No labels data".into()))?;
let labels: Vec<cache::CachedLabel> = serde_json::from_value(nodes.clone())?;
self.cache.labels.insert(cache_key.to_string(), labels);
}
}
let entry = self.cache.labels.get(cache_key).unwrap();
cache::find_labels(entry.value(), names)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_resolve_api_key_from_env() {
temp_env::with_var("LINEAR_API_KEY", Some("lin_api_test123"), || {
let key = LinearClient::resolve_api_key().unwrap();
assert_eq!(key, "lin_api_test123");
});
}
#[test]
fn test_resolve_api_key_empty_env() {
temp_env::with_var("LINEAR_API_KEY", Some(""), || {
let result = LinearClient::resolve_api_key();
assert!(result.is_err());
});
}
#[test]
fn test_resolve_api_key_missing() {
temp_env::with_var_unset("LINEAR_API_KEY", || {
let result = LinearClient::resolve_api_key();
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("LINEAR_API_KEY not found"));
});
}
#[test]
fn test_new_with_explicit_key() {
let client = LinearClient::new(Some("lin_api_explicit".into()), false);
assert!(client.is_ok());
}
}