Skip to main content

releasekit/platform/
gitlab.rs

1//! GitLab platform implementation.
2
3use crate::client::{HeaderMap, HttpClient};
4use crate::error::{Error, Result};
5use crate::model::{Asset, Release};
6use crate::platform::Forge;
7use serde::Deserialize;
8
9/// A GitLab client for fetching releases.
10///
11/// Wraps any [`HttpClient`] implementation and optionally stores a
12/// personal access token for private repositories.
13pub struct GitLab<C: HttpClient> {
14    client: C,
15    token: Option<String>,
16    base_url: String,
17}
18
19impl<C: HttpClient> GitLab<C> {
20    /// Creates a new GitLab client with the given HTTP backend.
21    ///
22    /// Defaults to `https://gitlab.com`. Use [`GitLab::with_base_url`] for
23    /// self-hosted instances.
24    pub fn new(client: C) -> Self {
25        Self {
26            client,
27            token: None,
28            base_url: "https://gitlab.com".to_string(),
29        }
30    }
31
32    /// Sets a custom base URL for the GitLab instance.
33    pub fn with_base_url(mut self, url: impl Into<String>) -> Self {
34        self.base_url = url.into().trim_end_matches('/').to_string();
35        self
36    }
37
38    /// Sets a GitLab personal access token for authentication.
39    pub fn with_token(mut self, token: impl Into<String>) -> Self {
40        self.token = Some(token.into());
41        self
42    }
43
44    /// Reads a token from the first set environment variable.
45    ///
46    /// Tries each name in order and uses the first one that is set.
47    /// Does not overwrite a token already set via [`GitLab::with_token`].
48    pub fn with_token_from_env(mut self, names: &[&str]) -> Self {
49        if self.token.is_none() {
50            for name in names {
51                if let Ok(val) = std::env::var(name) {
52                    let trimmed = val.trim().to_string();
53                    if !trimmed.is_empty() {
54                        self.token = Some(trimmed);
55                        break;
56                    }
57                }
58            }
59        }
60        self
61    }
62
63    fn auth_headers(&self) -> HeaderMap {
64        let mut headers = HeaderMap::new();
65        if let Some(ref token) = self.token {
66            headers.insert("PRIVATE-TOKEN", token.trim());
67        }
68        headers
69    }
70}
71
72#[derive(Deserialize)]
73struct GlRelease {
74    name: Option<String>,
75    tag_name: String,
76    #[serde(default)]
77    upcoming_release: bool,
78    released_at: String,
79    description: Option<String>,
80    assets: GlAssets,
81}
82
83#[derive(Deserialize)]
84struct GlAssets {
85    links: Vec<GlLink>,
86}
87
88#[derive(Deserialize)]
89struct GlLink {
90    name: String,
91    direct_asset_url: String,
92}
93
94impl From<GlRelease> for Release {
95    fn from(g: GlRelease) -> Self {
96        Release {
97            name: g.name,
98            tag: g.tag_name,
99            prerelease: g.upcoming_release,
100            published_at: g.released_at,
101            body: g.description,
102            assets: g.assets.links.into_iter().map(Asset::from).collect(),
103        }
104    }
105}
106
107impl From<GlLink> for Asset {
108    fn from(l: GlLink) -> Self {
109        Asset {
110            name: l.name,
111            size: None,
112            url: l.direct_asset_url,
113        }
114    }
115}
116
117impl<C: HttpClient> Forge for GitLab<C> {
118    /// Fetches releases for the given project.
119    ///
120    /// `project` can be either `owner/repo` format (will be URL-encoded) or
121    /// a numeric project ID.
122    fn fetch_releases(&self, project: &str, tag: Option<&str>) -> Result<Vec<Release>> {
123        let project_ref = if project.chars().all(|c| c.is_ascii_digit()) {
124            project.to_string()
125        } else {
126            urlencoding::encode(project).into_owned()
127        };
128        let url = match tag {
129            Some(t) => {
130                let enc_tag = urlencoding::encode(t);
131                format!(
132                    "{}/api/v4/projects/{project_ref}/releases/{enc_tag}",
133                    self.base_url
134                )
135            }
136            None => format!("{}/api/v4/projects/{project_ref}/releases", self.base_url),
137        };
138
139        let resp = self.client.get(&url, &self.auth_headers())?;
140        let raw: serde_json::Value = serde_json::from_str(&resp.body)?;
141
142        let releases: Vec<GlRelease> = match raw {
143            serde_json::Value::Array(arr) => serde_json::from_value(serde_json::Value::Array(arr))?,
144            obj @ serde_json::Value::Object(_) => vec![serde_json::from_value(obj)?],
145            _ => return Err(Error::NoReleases),
146        };
147
148        Ok(releases.into_iter().map(Release::from).collect())
149    }
150}