Skip to main content

rtb_vcs/
gitlab.rs

1//! GitLab backend — GitLab Cloud (`gitlab.com`) and self-hosted
2//! instances.
3//!
4//! # Authentication
5//!
6//! Personal Access Token via `PRIVATE-TOKEN: <token>` header (not
7//! `Authorization: Bearer`, which GitLab accepts but their own
8//! documentation prefers `PRIVATE-TOKEN` for PATs).
9//!
10//! # URL shape
11//!
12//! GitLab's REST API lives at `<host>/api/v4`. Projects are
13//! addressed by numeric ID or by URL-encoded path: we use the
14//! path form (`<group>%2F<project>`) so tool authors never need to
15//! look up IDs.
16//!
17//! # Lint exception
18//!
19//! Same as the other REST backends — `linkme::distributed_slice`
20//! emits `#[link_section]`.
21
22#![allow(unsafe_code)]
23
24use std::sync::Arc;
25
26use async_trait::async_trait;
27use linkme::distributed_slice;
28use secrecy::{ExposeSecret, SecretString};
29use tokio::io::AsyncRead;
30
31use crate::config::{GitlabParams, ReleaseSourceConfig};
32use crate::http;
33use crate::release::{
34    ProviderError, ProviderFactory, ProviderRegistration, RegisteredProvider, Release,
35    ReleaseAsset, ReleaseProvider, RELEASE_PROVIDERS,
36};
37
38/// GitLab-specific release provider.
39pub struct GitlabProvider {
40    client: reqwest::Client,
41    scheme: &'static str,
42    host: String,
43    project_path: String,
44    token: Option<SecretString>,
45}
46
47// ---------------------------------------------------------------------
48// Host normalisation
49// ---------------------------------------------------------------------
50
51/// Strip scheme + trailing slash. GitLab's API path is appended when
52/// URLs are constructed; users pass the bare host.
53fn normalise_host(raw: &str) -> String {
54    raw.trim_end_matches('/')
55        .trim_start_matches("https://")
56        .trim_start_matches("http://")
57        .to_string()
58}
59
60// ---------------------------------------------------------------------
61// Factory + registration
62// ---------------------------------------------------------------------
63
64/// Construct a [`GitlabProvider`] from parsed config. Enforces HTTPS
65/// unless the `allow_insecure_base_url` test escape hatch is set.
66///
67/// # Errors
68///
69/// [`ProviderError::InvalidConfig`] for empty or explicitly-http
70/// hosts, missing owner or repo, or a reqwest build failure.
71pub fn factory(
72    cfg: &ReleaseSourceConfig,
73    token: Option<SecretString>,
74) -> Result<Arc<dyn ReleaseProvider>, ProviderError> {
75    let ReleaseSourceConfig::Gitlab(params) = cfg else {
76        return Err(ProviderError::InvalidConfig(format!(
77            "gitlab factory called with non-gitlab config: source_type={}",
78            cfg.source_type()
79        )));
80    };
81    validate(params)?;
82
83    let host = normalise_host(&params.host);
84    let scheme = http::scheme_for(params.allow_insecure_base_url);
85    let project_path =
86        format!("{}%2F{}", http::urlencode(&params.owner), http::urlencode(&params.repo));
87    let client = http::build_client(params.timeout_seconds, params.allow_insecure_base_url)?;
88
89    Ok(Arc::new(GitlabProvider { client, scheme, host, project_path, token }))
90}
91
92fn validate(p: &GitlabParams) -> Result<(), ProviderError> {
93    if p.host.trim().is_empty() {
94        return Err(ProviderError::InvalidConfig("gitlab host must not be empty".into()));
95    }
96    if !p.allow_insecure_base_url && p.host.starts_with("http://") {
97        return Err(ProviderError::InvalidConfig(format!(
98            "gitlab host must be https; got {}",
99            p.host
100        )));
101    }
102    if p.owner.trim().is_empty() || p.repo.trim().is_empty() {
103        return Err(ProviderError::InvalidConfig("gitlab owner and repo must not be empty".into()));
104    }
105    Ok(())
106}
107
108/// Link-time registration entry.
109#[distributed_slice(RELEASE_PROVIDERS)]
110fn __register_gitlab() -> Box<dyn ProviderRegistration> {
111    Box::new(RegisteredProvider { source_type: "gitlab", factory: factory as ProviderFactory })
112}
113
114// ---------------------------------------------------------------------
115// Trait impl
116// ---------------------------------------------------------------------
117
118#[async_trait]
119impl ReleaseProvider for GitlabProvider {
120    async fn latest_release(&self) -> Result<Release, ProviderError> {
121        // GitLab doesn't have a dedicated "latest" endpoint — we list
122        // the most recent release and take the first non-draft entry.
123        let list: Vec<ApiRelease> = self.get_json(&self.releases_url(1)).await?;
124        let first = list
125            .into_iter()
126            .find(|r| !r.upcoming_release)
127            .ok_or_else(|| ProviderError::NotFound { what: "latest release".into() })?;
128        Ok(first.into_release())
129    }
130
131    async fn release_by_tag(&self, tag: &str) -> Result<Release, ProviderError> {
132        let url = format!("{}/{}", self.base_releases(), http::urlencode(tag));
133        let dto: ApiRelease = self.get_json(&url).await?;
134        Ok(dto.into_release())
135    }
136
137    async fn list_releases(&self, limit: usize) -> Result<Vec<Release>, ProviderError> {
138        // `per_page` maxes out at 100 in GitLab's public API.
139        let list: Vec<ApiRelease> = self.get_json(&self.releases_url(limit)).await?;
140        Ok(list.into_iter().take(limit).map(ApiRelease::into_release).collect())
141    }
142
143    async fn download_asset(
144        &self,
145        asset: &ReleaseAsset,
146    ) -> Result<(Box<dyn AsyncRead + Send + Unpin>, u64), ProviderError> {
147        let mut req = self.client.get(&asset.download_url);
148        if let Some(tok) = &self.token {
149            req = req.header("PRIVATE-TOKEN", tok.expose_secret());
150        }
151        let resp = req.send().await.map_err(|e| ProviderError::Transport(e.to_string()))?;
152        http::map_status_to_error(&resp, &self.host, false)?;
153        Ok(http::stream_body(resp))
154    }
155}
156
157// ---------------------------------------------------------------------
158// HTTP helpers (local)
159// ---------------------------------------------------------------------
160
161impl GitlabProvider {
162    fn base_releases(&self) -> String {
163        format!(
164            "{scheme}://{host}/api/v4/projects/{project}/releases",
165            scheme = self.scheme,
166            host = self.host,
167            project = self.project_path,
168        )
169    }
170
171    fn releases_url(&self, per_page: usize) -> String {
172        let per_page = per_page.clamp(1, 100);
173        format!("{}?per_page={}", self.base_releases(), per_page)
174    }
175
176    async fn get_json<T: serde::de::DeserializeOwned>(
177        &self,
178        url: &str,
179    ) -> Result<T, ProviderError> {
180        let mut req = self.client.get(url).header("Accept", "application/json");
181        if let Some(tok) = &self.token {
182            req = req.header("PRIVATE-TOKEN", tok.expose_secret());
183        }
184        let resp = req.send().await.map_err(|e| ProviderError::Transport(e.to_string()))?;
185        http::map_status_to_error(&resp, &self.host, false)?;
186        http::parse_json::<T>(resp).await
187    }
188}
189
190// ---------------------------------------------------------------------
191// DTOs
192// ---------------------------------------------------------------------
193
194#[derive(Debug, serde::Deserialize)]
195struct ApiRelease {
196    name: Option<String>,
197    tag_name: String,
198    #[serde(default)]
199    description: Option<String>,
200    #[serde(default)]
201    upcoming_release: bool,
202    created_at: String,
203    #[serde(default)]
204    released_at: Option<String>,
205    #[serde(default)]
206    assets: ApiAssets,
207}
208
209#[derive(Debug, Default, serde::Deserialize)]
210struct ApiAssets {
211    #[serde(default)]
212    links: Vec<ApiAssetLink>,
213}
214
215#[derive(Debug, serde::Deserialize)]
216struct ApiAssetLink {
217    id: u64,
218    name: String,
219    url: String,
220    #[serde(default)]
221    link_type: Option<String>,
222}
223
224impl ApiRelease {
225    fn into_release(self) -> Release {
226        let created_at =
227            parse_iso8601(&self.created_at).unwrap_or(time::OffsetDateTime::UNIX_EPOCH);
228        let published_at = self.released_at.as_deref().and_then(parse_iso8601);
229        let name = self.name.unwrap_or_else(|| self.tag_name.clone());
230        let body = self.description.unwrap_or_default();
231        let mut release = Release::new(name, self.tag_name.clone(), created_at);
232        release.body = body;
233        release.prerelease = self.tag_name.contains('-');
234        release.published_at = published_at;
235        release.assets = self
236            .assets
237            .links
238            .into_iter()
239            .map(|a| {
240                let mut asset = ReleaseAsset::new(a.id.to_string(), a.name, a.url);
241                asset.content_type = a.link_type;
242                asset
243            })
244            .collect();
245        release
246    }
247}
248
249fn parse_iso8601(s: &str) -> Option<time::OffsetDateTime> {
250    time::OffsetDateTime::parse(s, &time::format_description::well_known::Rfc3339).ok()
251}