Skip to main content

lean_ctx/core/providers/
gitlab.rs

1// SPDX-License-Identifier: Apache-2.0
2
3use 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    // A cached response is not evidence of current source authorization. Ask
68    // GitLab on every acquisition, including after a token or membership revoke.
69    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    // Use the same fresh authorization boundary as issues, not stale cache data.
120    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    /// Construct with an explicit config, bypassing env discovery. Used by the
192    /// hosted team server's managed connectors so each scheduled run carries its
193    /// own credential without mutating process-global env (which would race
194    /// across connectors).
195    #[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        // Item re-checks need a project to re-read from; without one GitLab
237        // stays list-only for governed reuse.
238        descriptor(
239            self.config
240                .as_ref()
241                .is_ok_and(|config| config.project_path.is_some()),
242        )
243    }
244
245    /// The source a stored item came from. A selected source binds its pinned
246    /// identity (re-verified on every request); an environment-configured one
247    /// binds host, project and the token that read it — another token or
248    /// project is another source.
249    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    /// Split the query half of an endpoint into raw `key=value` pairs the way a
407    /// server's query parser would: on `&`, then on the first `=`. Values are left
408    /// percent-encoded so the tests can assert on the wire form.
409    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    /// The filter value a caller passed must survive as exactly one query value.
432    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        // Comma is percent-encoded on the wire but still decodes to a label list.
446        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        // `+` means space in form decoding, so it must go out as %2B to survive.
551        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}