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 #[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}