Skip to main content

lean_ctx/core/providers/
jira.rs

1//! Jira provider — issues, sprints, and boards via the Jira REST API.
2//!
3//! Configuration via environment variables:
4//!   - `JIRA_URL`: Base URL (e.g., `https://company.atlassian.net`)
5//!   - `JIRA_EMAIL`: User email for Basic Auth
6//!   - `JIRA_TOKEN`: API token
7//!   - `JIRA_PROJECT`: Default project key (e.g., "PROJ")
8
9use crate::core::providers::{ContextProvider, ProviderItem, ProviderParams, ProviderResult};
10
11const B64_CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
12
13fn simple_base64(input: &[u8]) -> String {
14    let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
15    for chunk in input.chunks(3) {
16        let b0 = chunk[0] as u32;
17        let b1 = chunk.get(1).copied().unwrap_or(0) as u32;
18        let b2 = chunk.get(2).copied().unwrap_or(0) as u32;
19        let n = (b0 << 16) | (b1 << 8) | b2;
20        out.push(B64_CHARS[((n >> 18) & 63) as usize] as char);
21        out.push(B64_CHARS[((n >> 12) & 63) as usize] as char);
22        if chunk.len() > 1 {
23            out.push(B64_CHARS[((n >> 6) & 63) as usize] as char);
24        } else {
25            out.push('=');
26        }
27        if chunk.len() > 2 {
28            out.push(B64_CHARS[(n & 63) as usize] as char);
29        } else {
30            out.push('=');
31        }
32    }
33    out
34}
35
36pub struct JiraConfig {
37    pub base_url: String,
38    pub email: String,
39    pub token: String,
40    pub project: Option<String>,
41}
42
43impl JiraConfig {
44    pub fn from_env() -> Result<Self, String> {
45        let base_url = std::env::var("JIRA_URL").map_err(|_| "JIRA_URL not set")?;
46        let email = std::env::var("JIRA_EMAIL").map_err(|_| "JIRA_EMAIL not set")?;
47        let token = std::env::var("JIRA_TOKEN").map_err(|_| "JIRA_TOKEN not set")?;
48        let project = std::env::var("JIRA_PROJECT").ok();
49
50        Ok(Self {
51            base_url: base_url.trim_end_matches('/').to_string(),
52            email,
53            token,
54            project,
55        })
56    }
57
58    fn auth_header(&self) -> String {
59        let credentials = format!("{}:{}", self.email, self.token);
60        let encoded = simple_base64(credentials.as_bytes());
61        format!("Basic {encoded}")
62    }
63}
64
65pub struct JiraProvider {
66    config: Result<JiraConfig, String>,
67}
68
69impl Default for JiraProvider {
70    fn default() -> Self {
71        Self::new()
72    }
73}
74
75impl JiraProvider {
76    pub fn new() -> Self {
77        Self {
78            config: JiraConfig::from_env(),
79        }
80    }
81}
82
83impl ContextProvider for JiraProvider {
84    fn id(&self) -> &'static str {
85        "jira"
86    }
87
88    fn display_name(&self) -> &'static str {
89        "Jira"
90    }
91
92    fn supported_actions(&self) -> &[&str] {
93        &["issues", "sprints"]
94    }
95
96    fn execute(&self, action: &str, params: &ProviderParams) -> Result<ProviderResult, String> {
97        let config = self.config.as_ref().map_err(std::clone::Clone::clone)?;
98        match action {
99            "issues" => list_issues(config, params),
100            "sprints" => list_sprints(config, params),
101            _ => Err(format!("Unsupported action: {action}")),
102        }
103    }
104
105    fn cache_ttl_secs(&self) -> u64 {
106        120
107    }
108
109    fn requires_auth(&self) -> bool {
110        true
111    }
112
113    fn is_available(&self) -> bool {
114        self.config.is_ok()
115    }
116}
117
118fn list_issues(config: &JiraConfig, params: &ProviderParams) -> Result<ProviderResult, String> {
119    let limit = params.limit.unwrap_or(20);
120    let project = params
121        .state
122        .as_deref()
123        .or(config.project.as_deref())
124        .unwrap_or("*");
125
126    let jql = if project == "*" {
127        "ORDER BY updated DESC".to_string()
128    } else {
129        format!("project={project} ORDER BY updated DESC")
130    };
131
132    let url = format!(
133        "{}/rest/api/3/search?jql={}&maxResults={limit}",
134        config.base_url,
135        urlencoding::encode(&jql)
136    );
137
138    let response = ureq::get(&url)
139        .header("Authorization", &config.auth_header())
140        .header("Accept", "application/json")
141        .call()
142        .map_err(|e| format!("Jira API error: {e}"))?;
143
144    let text = response
145        .into_body()
146        .read_to_string()
147        .map_err(|e| format!("Jira read error: {e}"))?;
148    let body: serde_json::Value =
149        serde_json::from_str(&text).map_err(|e| format!("Jira JSON parse error: {e}"))?;
150
151    let total = body["total"].as_u64().unwrap_or(0) as usize;
152    let issues = body["issues"].as_array().cloned().unwrap_or_default();
153
154    let items: Vec<ProviderItem> = issues
155        .iter()
156        .map(|issue| {
157            let fields = &issue["fields"];
158            ProviderItem {
159                id: issue["key"].as_str().unwrap_or_default().to_string(),
160                title: fields["summary"].as_str().unwrap_or_default().to_string(),
161                state: fields["status"]["name"].as_str().map(String::from),
162                author: fields["reporter"]["displayName"].as_str().map(String::from),
163                created_at: fields["created"].as_str().map(String::from),
164                updated_at: fields["updated"].as_str().map(String::from),
165                url: Some(format!(
166                    "{}/browse/{}",
167                    config.base_url,
168                    issue["key"].as_str().unwrap_or_default()
169                )),
170                labels: fields["labels"]
171                    .as_array()
172                    .map(|arr| {
173                        arr.iter()
174                            .filter_map(|v| v.as_str().map(String::from))
175                            .collect()
176                    })
177                    .unwrap_or_default(),
178                body: fields["description"]
179                    .as_str()
180                    .map(String::from)
181                    .or_else(|| {
182                        fields["description"]["content"]
183                            .as_array()
184                            .map(|_| "[Jira rich text — see web UI]".to_string())
185                    }),
186            }
187        })
188        .collect();
189
190    Ok(ProviderResult {
191        provider: "jira".into(),
192        resource_type: "issues".into(),
193        items,
194        total_count: Some(total),
195        truncated: total > limit,
196    })
197}
198
199fn list_sprints(config: &JiraConfig, params: &ProviderParams) -> Result<ProviderResult, String> {
200    let board_id = params
201        .state
202        .as_deref()
203        .ok_or("Sprint listing requires a board ID via the 'state' parameter")?;
204
205    let limit = params.limit.unwrap_or(5);
206    let url = format!(
207        "{}/rest/agile/1.0/board/{board_id}/sprint?state=active,future&maxResults={limit}",
208        config.base_url
209    );
210
211    let response = ureq::get(&url)
212        .header("Authorization", &config.auth_header())
213        .header("Accept", "application/json")
214        .call()
215        .map_err(|e| format!("Jira Agile API error: {e}"))?;
216
217    let text = response
218        .into_body()
219        .read_to_string()
220        .map_err(|e| format!("Jira read error: {e}"))?;
221    let body: serde_json::Value =
222        serde_json::from_str(&text).map_err(|e| format!("Jira JSON parse error: {e}"))?;
223
224    let sprints = body["values"].as_array().cloned().unwrap_or_default();
225    let items: Vec<ProviderItem> = sprints
226        .iter()
227        .map(|s| ProviderItem {
228            id: s["id"].as_u64().map_or_else(String::new, |n| n.to_string()),
229            title: s["name"].as_str().unwrap_or_default().to_string(),
230            state: s["state"].as_str().map(String::from),
231            author: None,
232            created_at: s["startDate"].as_str().map(String::from),
233            updated_at: s["endDate"].as_str().map(String::from),
234            url: None,
235            labels: vec![],
236            body: s["goal"].as_str().map(String::from),
237        })
238        .collect();
239
240    Ok(ProviderResult {
241        provider: "jira".into(),
242        resource_type: "sprints".into(),
243        items,
244        total_count: Some(sprints.len()),
245        truncated: false,
246    })
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252
253    #[test]
254    fn jira_provider_is_unavailable_without_env() {
255        let _orig_url = std::env::var("JIRA_URL");
256        std::env::remove_var("JIRA_URL");
257        std::env::remove_var("JIRA_EMAIL");
258        std::env::remove_var("JIRA_TOKEN");
259
260        let provider = JiraProvider::new();
261        assert!(!provider.is_available());
262        assert_eq!(provider.id(), "jira");
263        assert!(provider.requires_auth());
264    }
265
266    #[test]
267    fn jira_provider_supported_actions() {
268        let provider = JiraProvider::new();
269        assert!(provider.supported_actions().contains(&"issues"));
270        assert!(provider.supported_actions().contains(&"sprints"));
271    }
272}