1use super::config::GitLabConfig;
4use super::provider_trait::{ContextProvider, ProviderParams};
5use super::{ProviderItem, ProviderResult};
6
7const DEFAULT_PER_PAGE: usize = 20;
8
9fn push_filter(url: &mut String, key: &str, value: &str) {
10 let value = urlencoding::encode(value);
11 url.push_str(&format!("&{key}={value}"));
12}
13
14fn issues_endpoint(
15 encoded_project: &str,
16 per_page: usize,
17 state: Option<&str>,
18 labels: Option<&str>,
19) -> String {
20 let mut url = format!(
21 "/projects/{encoded_project}/issues?per_page={per_page}&order_by=updated_at&sort=desc"
22 );
23 if let Some(s) = state {
24 push_filter(&mut url, "state", s);
25 }
26 if let Some(l) = labels {
27 push_filter(&mut url, "labels", l);
28 }
29 url
30}
31
32fn merge_requests_endpoint(encoded_project: &str, per_page: usize, state: Option<&str>) -> String {
33 let mut url = format!(
34 "/projects/{encoded_project}/merge_requests?per_page={per_page}&order_by=updated_at&sort=desc"
35 );
36 if let Some(s) = state {
37 push_filter(&mut url, "state", s);
38 }
39 url
40}
41
42fn pipelines_endpoint(encoded_project: &str, per_page: usize, status: Option<&str>) -> String {
43 let mut url = format!(
44 "/projects/{encoded_project}/pipelines?per_page={per_page}&order_by=updated_at&sort=desc"
45 );
46 if let Some(s) = status {
47 push_filter(&mut url, "status", s);
48 }
49 url
50}
51
52pub fn list_issues(
53 config: &GitLabConfig,
54 state: Option<&str>,
55 labels: Option<&str>,
56 limit: Option<usize>,
57) -> Result<ProviderResult, String> {
58 let project = config
59 .project_path
60 .as_deref()
61 .ok_or("No project path configured. Set CI_PROJECT_PATH or configure git remote.")?;
62 let encoded = urlencoding::encode(project);
63 let per_page = limit.unwrap_or(DEFAULT_PER_PAGE).min(100);
64
65 let url = issues_endpoint(&encoded, per_page, state, labels);
66
67 let body = api_get(config, &url)?;
70 let items: Vec<serde_json::Value> =
71 serde_json::from_str(&body).map_err(|e| format!("JSON parse error: {e}"))?;
72
73 let result = ProviderResult {
74 provider: "gitlab".to_string(),
75 resource_type: "issues".to_string(),
76 total_count: None,
77 truncated: items.len() >= per_page,
78 items: items.iter().map(parse_issue).collect(),
79 };
80
81 Ok(result)
82}
83
84pub fn show_issue(config: &GitLabConfig, iid: u64) -> Result<ProviderResult, String> {
85 let project = config
86 .project_path
87 .as_deref()
88 .ok_or("No project path configured.")?;
89 let encoded = urlencoding::encode(project);
90 let url = format!("/projects/{encoded}/issues/{iid}");
91
92 let body = api_get(config, &url)?;
93 let issue: serde_json::Value =
94 serde_json::from_str(&body).map_err(|e| format!("JSON parse error: {e}"))?;
95
96 Ok(ProviderResult {
97 provider: "gitlab".to_string(),
98 resource_type: "issue".to_string(),
99 total_count: Some(1),
100 truncated: false,
101 items: vec![parse_issue(&issue)],
102 })
103}
104
105pub fn list_mrs(
106 config: &GitLabConfig,
107 state: Option<&str>,
108 limit: Option<usize>,
109) -> Result<ProviderResult, String> {
110 let project = config
111 .project_path
112 .as_deref()
113 .ok_or("No project path configured.")?;
114 let encoded = urlencoding::encode(project);
115 let per_page = limit.unwrap_or(DEFAULT_PER_PAGE).min(100);
116
117 let url = merge_requests_endpoint(&encoded, per_page, state);
118
119 let body = api_get(config, &url)?;
121 let items: Vec<serde_json::Value> =
122 serde_json::from_str(&body).map_err(|e| format!("JSON parse error: {e}"))?;
123
124 let result = ProviderResult {
125 provider: "gitlab".to_string(),
126 resource_type: "merge_requests".to_string(),
127 total_count: None,
128 truncated: items.len() >= per_page,
129 items: items.iter().map(parse_mr).collect(),
130 };
131
132 Ok(result)
133}
134
135pub fn list_pipelines(
136 config: &GitLabConfig,
137 status: Option<&str>,
138 limit: Option<usize>,
139) -> Result<ProviderResult, String> {
140 let project = config
141 .project_path
142 .as_deref()
143 .ok_or("No project path configured.")?;
144 let encoded = urlencoding::encode(project);
145 let per_page = limit.unwrap_or(DEFAULT_PER_PAGE).min(100);
146
147 let url = pipelines_endpoint(&encoded, per_page, status);
148
149 let body = api_get(config, &url)?;
150 let items: Vec<serde_json::Value> =
151 serde_json::from_str(&body).map_err(|e| format!("JSON parse error: {e}"))?;
152
153 Ok(ProviderResult {
154 provider: "gitlab".to_string(),
155 resource_type: "pipelines".to_string(),
156 total_count: None,
157 truncated: items.len() >= per_page,
158 items: items
159 .iter()
160 .map(|p| ProviderItem {
161 id: p["id"].as_u64().unwrap_or(0).to_string(),
162 title: p["ref"].as_str().unwrap_or("").to_string(),
163 state: p["status"].as_str().map(std::string::ToString::to_string),
164 author: None,
165 created_at: p["created_at"]
166 .as_str()
167 .map(std::string::ToString::to_string),
168 updated_at: p["updated_at"]
169 .as_str()
170 .map(std::string::ToString::to_string),
171 url: p["web_url"].as_str().map(std::string::ToString::to_string),
172 labels: Vec::new(),
173 body: None,
174 ..Default::default()
175 })
176 .collect(),
177 })
178}
179
180pub struct GitLabProvider {
181 config: Result<GitLabConfig, String>,
182}
183
184impl GitLabProvider {
185 pub fn new() -> Self {
186 Self {
187 config: GitLabConfig::from_session(),
188 }
189 }
190
191 #[must_use]
196 pub fn with_config(config: GitLabConfig) -> Self {
197 Self { config: Ok(config) }
198 }
199}
200
201impl Default for GitLabProvider {
202 fn default() -> Self {
203 Self::new()
204 }
205}
206
207impl ContextProvider for GitLabProvider {
208 fn id(&self) -> &'static str {
209 "gitlab"
210 }
211
212 fn display_name(&self) -> &'static str {
213 "GitLab"
214 }
215
216 fn supported_actions(&self) -> &[&str] {
217 &["issues", "merge_requests", "pipelines"]
218 }
219
220 fn execute(&self, action: &str, params: &ProviderParams) -> Result<ProviderResult, String> {
221 super::selected_gitlab::check_project(params.project.as_deref())?;
222 let config = self.config.as_ref().map_err(std::clone::Clone::clone)?;
223 match action {
224 "issues" => list_issues(config, params.state.as_deref(), None, params.limit),
225 "merge_requests" | "mrs" => list_mrs(config, params.state.as_deref(), params.limit),
226 "pipelines" => list_pipelines(config, params.state.as_deref(), params.limit),
227 _ => Err(format!("Unknown GitLab action: {action}")),
228 }
229 }
230
231 fn cache_ttl_secs(&self) -> u64 {
232 0
233 }
234
235 fn descriptor(&self) -> super::provider_trait::ConnectorDescriptor {
236 descriptor(
239 self.config
240 .as_ref()
241 .is_ok_and(|config| config.project_path.is_some()),
242 )
243 }
244
245 fn reuse_binding(&self, action: &str, params: &ProviderParams) -> Option<String> {
250 if !matches!(action, "issues" | "merge_requests") {
251 return None;
252 }
253 super::selected_gitlab::check_project(params.project.as_deref()).ok()?;
254 let config = self.config.as_ref().ok()?;
255 if super::selected_gitlab::config().is_some() {
256 return super::selected_gitlab::reuse_binding(config);
257 }
258 super::provenance::digest(&serde_json::json!({
259 "version": 1, "provider": "gitlab", "host": config.host,
260 "project": config.project_path.as_deref()?, "credential": config.token,
261 }))
262 }
263
264 fn reacquire_item(
265 &self,
266 action: &str,
267 params: &ProviderParams,
268 item_id: &str,
269 ) -> Result<ProviderResult, String> {
270 if self.reuse_binding(action, params).is_none() {
271 return Err("selected source is unavailable for item reauthorization".into());
272 }
273 let id: u64 = item_id
274 .parse()
275 .map_err(|_| "invalid stored GitLab item ID")?;
276 if id == 0 || id.to_string() != item_id {
277 return Err("invalid stored GitLab item ID".into());
278 }
279 let config = self.config.as_ref().map_err(Clone::clone)?;
280 let project = config
281 .project_path
282 .as_deref()
283 .ok_or("selected project missing")?;
284 let endpoint = format!("/projects/{}/{action}/{id}", urlencoding::encode(project));
285 let body = api_get(config, &endpoint)?;
286 let value = serde_json::from_str(&body).map_err(|_| "invalid GitLab item response")?;
287 let item = match action {
288 "issues" => parse_issue(&value),
289 "merge_requests" => parse_mr(&value),
290 _ => return Err("unsupported stored GitLab resource".into()),
291 };
292 Ok(ProviderResult {
293 provider: "gitlab".into(),
294 resource_type: action.into(),
295 items: vec![item],
296 total_count: Some(1),
297 truncated: false,
298 })
299 }
300
301 fn is_available(&self) -> bool {
302 self.config.is_ok()
303 }
304}
305
306pub(crate) fn descriptor(bound: bool) -> super::provider_trait::ConnectorDescriptor {
307 use super::provider_trait::{CapabilityLevel as L, ConnectorDescriptor as C};
308 C {
309 principal_binding: "personal access token (PRIVATE-TOKEN), bound with host and project; a selected source is pinned by id and namespace",
310 search: L::Partial,
311 list: L::Supported,
312 get: L::Supported,
313 delta: L::Unsupported,
314 revalidate_acl: if bound { L::Supported } else { L::Unsupported },
315 revalidate_revision: if bound { L::Supported } else { L::Unsupported },
316 delete_event: if bound { L::Partial } else { L::Unsupported },
317 content_trust: C::UNTRUSTED,
318 write_surface: C::NO_WRITES,
319 failure_policy: C::FAIL_CLOSED,
320 notes: "search = state/label filters, no full text; issues and merge requests re-checked per item with \
321 the principal's token against the configured project (current item digest); a selected source \
322 also re-verifies its pinned project identity on every request; without a configured project \
323 list-only; deletion seen as 404 on re-check, no event feed; pipelines are list-only",
324 }
325}
326
327fn api_get(config: &GitLabConfig, endpoint: &str) -> Result<String, String> {
328 super::selected_gitlab::authorize(config)?;
329 let url = config.api_url(endpoint);
330 super::hardened_http::provider_get_with_headers(
331 "gitlab",
332 &url,
333 &[("PRIVATE-TOKEN", config.token.as_str())],
334 )
335 .into_body()
336 .map_err(|e| format!("GitLab API error: {e}"))
337}
338
339fn parse_issue(v: &serde_json::Value) -> ProviderItem {
340 ProviderItem {
341 id: v["iid"].as_u64().unwrap_or(0).to_string(),
342 title: v["title"].as_str().unwrap_or("").to_string(),
343 state: v["state"].as_str().map(std::string::ToString::to_string),
344 author: v["author"]["username"]
345 .as_str()
346 .map(std::string::ToString::to_string),
347 created_at: v["created_at"]
348 .as_str()
349 .map(std::string::ToString::to_string),
350 updated_at: v["updated_at"]
351 .as_str()
352 .map(std::string::ToString::to_string),
353 url: v["web_url"].as_str().map(std::string::ToString::to_string),
354 labels: v["labels"]
355 .as_array()
356 .map(|arr| {
357 arr.iter()
358 .filter_map(|l| l.as_str().map(std::string::ToString::to_string))
359 .collect()
360 })
361 .unwrap_or_default(),
362 body: v["description"]
363 .as_str()
364 .map(std::string::ToString::to_string),
365 ..Default::default()
366 }
367}
368
369fn parse_mr(v: &serde_json::Value) -> ProviderItem {
370 ProviderItem {
371 id: v["iid"].as_u64().unwrap_or(0).to_string(),
372 title: v["title"].as_str().unwrap_or("").to_string(),
373 state: v["state"].as_str().map(std::string::ToString::to_string),
374 author: v["author"]["username"]
375 .as_str()
376 .map(std::string::ToString::to_string),
377 created_at: v["created_at"]
378 .as_str()
379 .map(std::string::ToString::to_string),
380 updated_at: v["updated_at"]
381 .as_str()
382 .map(std::string::ToString::to_string),
383 url: v["web_url"].as_str().map(std::string::ToString::to_string),
384 labels: v["labels"]
385 .as_array()
386 .map(|arr| {
387 arr.iter()
388 .filter_map(|l| l.as_str().map(std::string::ToString::to_string))
389 .collect()
390 })
391 .unwrap_or_default(),
392 body: v["description"]
393 .as_str()
394 .map(std::string::ToString::to_string),
395 ..Default::default()
396 }
397}
398
399#[cfg(test)]
400mod authorization_tests;
401
402#[cfg(test)]
403mod tests {
404 use super::{issues_endpoint, merge_requests_endpoint, pipelines_endpoint};
405
406 fn query_pairs(endpoint: &str) -> Vec<(String, String)> {
410 let query = endpoint.split_once('?').expect("endpoint has a query").1;
411 query
412 .split('&')
413 .map(|pair| {
414 let (key, value) = pair.split_once('=').unwrap_or((pair, ""));
415 (key.to_owned(), value.to_owned())
416 })
417 .collect()
418 }
419
420 fn value_of(endpoint: &str, key: &str) -> Option<String> {
421 query_pairs(endpoint)
422 .into_iter()
423 .find(|(k, _)| k == key)
424 .map(|(_, v)| v)
425 }
426
427 fn keys_of(endpoint: &str) -> Vec<String> {
428 query_pairs(endpoint).into_iter().map(|(k, _)| k).collect()
429 }
430
431 fn assert_round_trips(endpoint: &str, key: &str, original: &str) {
433 let encoded = value_of(endpoint, key).expect("filter parameter present");
434 let decoded = urlencoding::decode(&encoded).expect("value is valid percent-encoding");
435 assert_eq!(
436 decoded, original,
437 "filter {key} must decode back to the caller's exact value"
438 );
439 }
440
441 #[test]
442 fn plain_filters_keep_their_literal_form() {
443 let endpoint = issues_endpoint("group%2Fproject", 20, Some("opened"), Some("bug,urgent"));
444 assert_eq!(value_of(&endpoint, "state").as_deref(), Some("opened"));
445 assert_round_trips(&endpoint, "labels", "bug,urgent");
447 assert_eq!(value_of(&endpoint, "per_page").as_deref(), Some("20"));
448 assert_eq!(
449 value_of(&endpoint, "order_by").as_deref(),
450 Some("updated_at")
451 );
452 assert_eq!(value_of(&endpoint, "sort").as_deref(), Some("desc"));
453 }
454
455 #[test]
456 fn absent_filters_add_no_parameters() {
457 assert_eq!(
458 keys_of(&issues_endpoint("p", 20, None, None)),
459 ["per_page", "order_by", "sort"]
460 );
461 assert_eq!(
462 keys_of(&merge_requests_endpoint("p", 20, None)),
463 ["per_page", "order_by", "sort"]
464 );
465 assert_eq!(
466 keys_of(&pipelines_endpoint("p", 20, None)),
467 ["per_page", "order_by", "sort"]
468 );
469 }
470
471 #[test]
472 fn ampersand_in_issue_state_cannot_inject_a_parameter() {
473 let hostile = "opened&per_page=100";
474 let endpoint = issues_endpoint("p", 20, Some(hostile), None);
475 assert_eq!(
476 keys_of(&endpoint),
477 ["per_page", "order_by", "sort", "state"],
478 "hostile filter must not add a query parameter"
479 );
480 assert_eq!(value_of(&endpoint, "per_page").as_deref(), Some("20"));
481 assert_round_trips(&endpoint, "state", hostile);
482 }
483
484 #[test]
485 fn ampersand_in_labels_cannot_inject_a_parameter() {
486 let hostile = "bug&state=closed";
487 let endpoint = issues_endpoint("p", 20, None, Some(hostile));
488 assert_eq!(
489 keys_of(&endpoint),
490 ["per_page", "order_by", "sort", "labels"]
491 );
492 assert_round_trips(&endpoint, "labels", hostile);
493 }
494
495 #[test]
496 fn ampersand_in_mr_state_cannot_inject_a_parameter() {
497 let hostile = "opened&per_page=100";
498 let endpoint = merge_requests_endpoint("p", 20, Some(hostile));
499 assert_eq!(
500 keys_of(&endpoint),
501 ["per_page", "order_by", "sort", "state"]
502 );
503 assert_eq!(value_of(&endpoint, "per_page").as_deref(), Some("20"));
504 assert_round_trips(&endpoint, "state", hostile);
505 }
506
507 #[test]
508 fn ampersand_in_pipeline_status_cannot_inject_a_parameter() {
509 let hostile = "success&ref=main";
510 let endpoint = pipelines_endpoint("p", 20, Some(hostile));
511 assert_eq!(
512 keys_of(&endpoint),
513 ["per_page", "order_by", "sort", "status"]
514 );
515 assert_round_trips(&endpoint, "status", hostile);
516 }
517
518 #[test]
519 fn fragment_marker_cannot_truncate_the_query() {
520 let hostile = "opened#frag";
521 let endpoint = issues_endpoint("p", 20, Some(hostile), None);
522 assert!(
523 !endpoint.contains('#'),
524 "a literal '#' would make the rest of the query a fragment: {endpoint}"
525 );
526 assert_round_trips(&endpoint, "state", hostile);
527 }
528
529 #[test]
530 fn reserved_characters_stay_inside_one_value() {
531 for original in [
532 "opened=closed",
533 "needs review",
534 "rust+lang",
535 "a=b&c=d#e f",
536 "50%",
537 ] {
538 let endpoint = issues_endpoint("p", 20, Some(original), None);
539 assert_eq!(
540 keys_of(&endpoint),
541 ["per_page", "order_by", "sort", "state"],
542 "value {original:?} must stay one parameter"
543 );
544 assert_round_trips(&endpoint, "state", original);
545 }
546 }
547
548 #[test]
549 fn literal_plus_is_not_decoded_as_a_space() {
550 let endpoint = issues_endpoint("p", 20, Some("rust+lang"), None);
552 assert_eq!(value_of(&endpoint, "state").as_deref(), Some("rust%2Blang"));
553 assert_round_trips(&endpoint, "state", "rust+lang");
554 }
555
556 #[test]
557 fn space_is_percent_encoded_not_left_raw() {
558 let endpoint = issues_endpoint("p", 20, Some("needs review"), None);
559 assert!(
560 !endpoint.contains(' '),
561 "a raw space would break the HTTP request line: {endpoint}"
562 );
563 assert_eq!(
564 value_of(&endpoint, "state").as_deref(),
565 Some("needs%20review")
566 );
567 }
568
569 #[test]
570 fn unicode_filter_values_round_trip() {
571 for original in ["priorität:hoch", "标签", "naïve café", "🙂"] {
572 let endpoint = issues_endpoint("p", 20, None, Some(original));
573 assert!(endpoint.is_ascii(), "wire form must be ASCII: {endpoint}");
574 assert_round_trips(&endpoint, "labels", original);
575 }
576 }
577
578 #[test]
579 fn project_path_stays_encoded_alongside_filters() {
580 let endpoint = issues_endpoint(
581 &urlencoding::encode("group/sub/project"),
582 50,
583 Some("opened"),
584 None,
585 );
586 assert!(endpoint.starts_with("/projects/group%2Fsub%2Fproject/issues?"));
587 assert_eq!(value_of(&endpoint, "per_page").as_deref(), Some("50"));
588 }
589}