Skip to main content

lean_ctx/core/providers/
gitlab.rs

1use super::cache;
2use super::config::GitLabConfig;
3use super::provider_trait::{ContextProvider, ProviderParams};
4use super::{ProviderItem, ProviderResult};
5
6const DEFAULT_PER_PAGE: usize = 20;
7const CACHE_TTL_SECS: u64 = 120;
8
9pub fn list_issues(
10    config: &GitLabConfig,
11    state: Option<&str>,
12    labels: Option<&str>,
13    limit: Option<usize>,
14) -> Result<ProviderResult, String> {
15    let project = config
16        .project_path
17        .as_deref()
18        .ok_or("No project path configured. Set CI_PROJECT_PATH or configure git remote.")?;
19    let encoded = urlencoding::encode(project);
20    let per_page = limit.unwrap_or(DEFAULT_PER_PAGE).min(100);
21
22    let mut url =
23        format!("/projects/{encoded}/issues?per_page={per_page}&order_by=updated_at&sort=desc");
24    if let Some(s) = state {
25        url.push_str(&format!("&state={s}"));
26    }
27    if let Some(l) = labels {
28        url.push_str(&format!("&labels={l}"));
29    }
30
31    let cache_key = format!("gitlab:issues:{project}:{state:?}:{labels:?}:{per_page}");
32    if let Some(cached) = cache::get_cached(&cache_key)
33        && let Ok(result) = serde_json::from_str::<ProviderResult>(&cached)
34    {
35        return Ok(result);
36    }
37
38    let body = api_get(config, &url)?;
39    let items: Vec<serde_json::Value> =
40        serde_json::from_str(&body).map_err(|e| format!("JSON parse error: {e}"))?;
41
42    let result = ProviderResult {
43        provider: "gitlab".to_string(),
44        resource_type: "issues".to_string(),
45        total_count: None,
46        truncated: items.len() >= per_page,
47        items: items.iter().map(parse_issue).collect(),
48    };
49
50    if let Ok(json) = serde_json::to_string(&result) {
51        cache::set_cached(&cache_key, &json, CACHE_TTL_SECS);
52    }
53    Ok(result)
54}
55
56pub fn show_issue(config: &GitLabConfig, iid: u64) -> Result<ProviderResult, String> {
57    let project = config
58        .project_path
59        .as_deref()
60        .ok_or("No project path configured.")?;
61    let encoded = urlencoding::encode(project);
62    let url = format!("/projects/{encoded}/issues/{iid}");
63
64    let body = api_get(config, &url)?;
65    let issue: serde_json::Value =
66        serde_json::from_str(&body).map_err(|e| format!("JSON parse error: {e}"))?;
67
68    Ok(ProviderResult {
69        provider: "gitlab".to_string(),
70        resource_type: "issue".to_string(),
71        total_count: Some(1),
72        truncated: false,
73        items: vec![parse_issue(&issue)],
74    })
75}
76
77pub fn list_mrs(
78    config: &GitLabConfig,
79    state: Option<&str>,
80    limit: Option<usize>,
81) -> Result<ProviderResult, String> {
82    let project = config
83        .project_path
84        .as_deref()
85        .ok_or("No project path configured.")?;
86    let encoded = urlencoding::encode(project);
87    let per_page = limit.unwrap_or(DEFAULT_PER_PAGE).min(100);
88
89    let mut url = format!(
90        "/projects/{encoded}/merge_requests?per_page={per_page}&order_by=updated_at&sort=desc"
91    );
92    if let Some(s) = state {
93        url.push_str(&format!("&state={s}"));
94    }
95
96    let cache_key = format!("gitlab:mrs:{project}:{state:?}:{per_page}");
97    if let Some(cached) = cache::get_cached(&cache_key)
98        && let Ok(result) = serde_json::from_str::<ProviderResult>(&cached)
99    {
100        return Ok(result);
101    }
102
103    let body = api_get(config, &url)?;
104    let items: Vec<serde_json::Value> =
105        serde_json::from_str(&body).map_err(|e| format!("JSON parse error: {e}"))?;
106
107    let result = ProviderResult {
108        provider: "gitlab".to_string(),
109        resource_type: "merge_requests".to_string(),
110        total_count: None,
111        truncated: items.len() >= per_page,
112        items: items.iter().map(parse_mr).collect(),
113    };
114
115    if let Ok(json) = serde_json::to_string(&result) {
116        cache::set_cached(&cache_key, &json, CACHE_TTL_SECS);
117    }
118    Ok(result)
119}
120
121pub fn list_pipelines(
122    config: &GitLabConfig,
123    status: Option<&str>,
124    limit: Option<usize>,
125) -> Result<ProviderResult, String> {
126    let project = config
127        .project_path
128        .as_deref()
129        .ok_or("No project path configured.")?;
130    let encoded = urlencoding::encode(project);
131    let per_page = limit.unwrap_or(DEFAULT_PER_PAGE).min(100);
132
133    let mut url =
134        format!("/projects/{encoded}/pipelines?per_page={per_page}&order_by=updated_at&sort=desc");
135    if let Some(s) = status {
136        url.push_str(&format!("&status={s}"));
137    }
138
139    let body = api_get(config, &url)?;
140    let items: Vec<serde_json::Value> =
141        serde_json::from_str(&body).map_err(|e| format!("JSON parse error: {e}"))?;
142
143    Ok(ProviderResult {
144        provider: "gitlab".to_string(),
145        resource_type: "pipelines".to_string(),
146        total_count: None,
147        truncated: items.len() >= per_page,
148        items: items
149            .iter()
150            .map(|p| ProviderItem {
151                id: p["id"].as_u64().unwrap_or(0).to_string(),
152                title: p["ref"].as_str().unwrap_or("").to_string(),
153                state: p["status"].as_str().map(std::string::ToString::to_string),
154                author: None,
155                created_at: p["created_at"]
156                    .as_str()
157                    .map(std::string::ToString::to_string),
158                updated_at: p["updated_at"]
159                    .as_str()
160                    .map(std::string::ToString::to_string),
161                url: p["web_url"].as_str().map(std::string::ToString::to_string),
162                labels: Vec::new(),
163                body: None,
164                ..Default::default()
165            })
166            .collect(),
167    })
168}
169
170pub struct GitLabProvider {
171    config: Result<GitLabConfig, String>,
172}
173
174impl GitLabProvider {
175    pub fn new() -> Self {
176        Self {
177            config: GitLabConfig::from_env(),
178        }
179    }
180
181    /// Construct with an explicit config, bypassing env discovery. Used by the
182    /// hosted team server's managed connectors so each scheduled run carries its
183    /// own credential without mutating process-global env (which would race
184    /// across connectors).
185    #[must_use]
186    pub fn with_config(config: GitLabConfig) -> Self {
187        Self { config: Ok(config) }
188    }
189}
190
191impl Default for GitLabProvider {
192    fn default() -> Self {
193        Self::new()
194    }
195}
196
197impl ContextProvider for GitLabProvider {
198    fn id(&self) -> &'static str {
199        "gitlab"
200    }
201
202    fn display_name(&self) -> &'static str {
203        "GitLab"
204    }
205
206    fn supported_actions(&self) -> &[&str] {
207        &["issues", "merge_requests", "pipelines"]
208    }
209
210    fn execute(&self, action: &str, params: &ProviderParams) -> Result<ProviderResult, String> {
211        let config = self.config.as_ref().map_err(std::clone::Clone::clone)?;
212        match action {
213            "issues" => list_issues(config, params.state.as_deref(), None, params.limit),
214            "merge_requests" | "mrs" => list_mrs(config, params.state.as_deref(), params.limit),
215            "pipelines" => list_pipelines(config, params.state.as_deref(), params.limit),
216            _ => Err(format!("Unknown GitLab action: {action}")),
217        }
218    }
219
220    fn cache_ttl_secs(&self) -> u64 {
221        CACHE_TTL_SECS
222    }
223
224    fn is_available(&self) -> bool {
225        self.config.is_ok()
226    }
227}
228
229fn api_get(config: &GitLabConfig, endpoint: &str) -> Result<String, String> {
230    let url = config.api_url(endpoint);
231    let response = ureq::get(&url)
232        .header("PRIVATE-TOKEN", &config.token)
233        .call()
234        .map_err(|e| format!("GitLab API error: {e}"))?;
235
236    if response.status() != 200 {
237        return Err(format!("GitLab API returned status {}", response.status()));
238    }
239
240    response
241        .into_body()
242        .read_to_string()
243        .map_err(|e| format!("Failed to read response: {e}"))
244}
245
246fn parse_issue(v: &serde_json::Value) -> ProviderItem {
247    ProviderItem {
248        id: v["iid"].as_u64().unwrap_or(0).to_string(),
249        title: v["title"].as_str().unwrap_or("").to_string(),
250        state: v["state"].as_str().map(std::string::ToString::to_string),
251        author: v["author"]["username"]
252            .as_str()
253            .map(std::string::ToString::to_string),
254        created_at: v["created_at"]
255            .as_str()
256            .map(std::string::ToString::to_string),
257        updated_at: v["updated_at"]
258            .as_str()
259            .map(std::string::ToString::to_string),
260        url: v["web_url"].as_str().map(std::string::ToString::to_string),
261        labels: v["labels"]
262            .as_array()
263            .map(|arr| {
264                arr.iter()
265                    .filter_map(|l| l.as_str().map(std::string::ToString::to_string))
266                    .collect()
267            })
268            .unwrap_or_default(),
269        body: v["description"]
270            .as_str()
271            .map(std::string::ToString::to_string),
272        ..Default::default()
273    }
274}
275
276fn parse_mr(v: &serde_json::Value) -> ProviderItem {
277    ProviderItem {
278        id: v["iid"].as_u64().unwrap_or(0).to_string(),
279        title: v["title"].as_str().unwrap_or("").to_string(),
280        state: v["state"].as_str().map(std::string::ToString::to_string),
281        author: v["author"]["username"]
282            .as_str()
283            .map(std::string::ToString::to_string),
284        created_at: v["created_at"]
285            .as_str()
286            .map(std::string::ToString::to_string),
287        updated_at: v["updated_at"]
288            .as_str()
289            .map(std::string::ToString::to_string),
290        url: v["web_url"].as_str().map(std::string::ToString::to_string),
291        labels: v["labels"]
292            .as_array()
293            .map(|arr| {
294                arr.iter()
295                    .filter_map(|l| l.as_str().map(std::string::ToString::to_string))
296                    .collect()
297            })
298            .unwrap_or_default(),
299        body: v["description"]
300            .as_str()
301            .map(std::string::ToString::to_string),
302        ..Default::default()
303    }
304}