lean_ctx/core/providers/
jira.rs1use 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}