Skip to main content

hs_relmon/
gitlab.rs

1// SPDX-License-Identifier: MPL-2.0
2
3use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
4use serde::Deserialize;
5
6/// A GitLab issue.
7#[derive(Debug, Deserialize)]
8pub struct Issue {
9    pub iid: u64,
10    pub title: String,
11    pub description: Option<String>,
12    pub state: String,
13    pub web_url: String,
14}
15
16/// Client for the GitLab REST API v4.
17pub struct Client {
18    http: reqwest::blocking::Client,
19    base_url: String,
20    project_path: String,
21}
22
23impl Client {
24    /// Create a client for the given GitLab project URL.
25    ///
26    /// Reads the authentication token from `GITLAB_TOKEN`, falling
27    /// back to the config file.
28    pub fn from_project_url(
29        url: &str,
30    ) -> Result<Self, Box<dyn std::error::Error>> {
31        let token = std::env::var("GITLAB_TOKEN").ok().or_else(|| {
32            crate::config::load()
33                .ok()
34                .and_then(|c| c.gitlab.map(|g| g.access_token))
35        });
36        let token = token.ok_or(
37            "GitLab token not found; set GITLAB_TOKEN \
38            or run 'hs-relmon config'",
39        )?;
40        let (base_url, project_path) = parse_project_url(url)?;
41        Self::new(&base_url, &project_path, &token)
42    }
43
44    /// Create a client with explicit parameters.
45    pub fn new(
46        base_url: &str,
47        project_path: &str,
48        token: &str,
49    ) -> Result<Self, Box<dyn std::error::Error>> {
50        let mut headers = HeaderMap::new();
51        headers.insert(
52            HeaderName::from_static("private-token"),
53            HeaderValue::from_str(token)?,
54        );
55        let http = reqwest::blocking::Client::builder()
56            .user_agent("hs-relmon/0.2.0")
57            .default_headers(headers)
58            .build()?;
59        Ok(Self {
60            http,
61            base_url: base_url.trim_end_matches('/').to_string(),
62            project_path: project_path.to_string(),
63        })
64    }
65
66    /// Create a new issue.
67    pub fn create_issue(
68        &self,
69        title: &str,
70        description: Option<&str>,
71        labels: Option<&str>,
72    ) -> Result<Issue, Box<dyn std::error::Error>> {
73        let mut body = serde_json::json!({"title": title});
74        if let Some(desc) = description {
75            body["description"] = desc.into();
76        }
77        if let Some(labels) = labels {
78            body["labels"] = labels.into();
79        }
80
81        let resp = self.http.post(&self.issues_url()).json(&body).send()?;
82        check_response(resp)
83    }
84
85    /// List issues matching a label and state.
86    pub fn list_issues(
87        &self,
88        label: &str,
89        state: &str,
90    ) -> Result<Vec<Issue>, Box<dyn std::error::Error>> {
91        let resp = self
92            .http
93            .get(&self.issues_url())
94            .query(&[("labels", label), ("state", state)])
95            .send()?;
96        if !resp.status().is_success() {
97            let status = resp.status();
98            let text = resp.text()?;
99            return Err(
100                format!("GitLab API error {status}: {text}").into(),
101            );
102        }
103        Ok(resp.json()?)
104    }
105
106    /// Edit an existing issue.
107    pub fn edit_issue(
108        &self,
109        iid: u64,
110        updates: &IssueUpdate,
111    ) -> Result<Issue, Box<dyn std::error::Error>> {
112        let body = serde_json::to_value(updates)?;
113        let resp = self
114            .http
115            .put(&format!("{}/{iid}", self.issues_url()))
116            .json(&body)
117            .send()?;
118        check_response(resp)
119    }
120
121    fn issues_url(&self) -> String {
122        let encoded = self.project_path.replace('/', "%2F");
123        format!(
124            "{}/api/v4/projects/{}/issues",
125            self.base_url, encoded
126        )
127    }
128}
129
130/// Parameters for editing an issue.
131#[derive(Debug, Default, serde::Serialize)]
132pub struct IssueUpdate {
133    #[serde(skip_serializing_if = "Option::is_none")]
134    pub title: Option<String>,
135    #[serde(skip_serializing_if = "Option::is_none")]
136    pub description: Option<String>,
137    #[serde(skip_serializing_if = "Option::is_none")]
138    pub add_labels: Option<String>,
139    #[serde(skip_serializing_if = "Option::is_none")]
140    pub remove_labels: Option<String>,
141    #[serde(skip_serializing_if = "Option::is_none")]
142    pub state_event: Option<String>,
143}
144
145fn check_response(
146    resp: reqwest::blocking::Response,
147) -> Result<Issue, Box<dyn std::error::Error>> {
148    if !resp.status().is_success() {
149        let status = resp.status();
150        let text = resp.text()?;
151        return Err(format!("GitLab API error {status}: {text}").into());
152    }
153    Ok(resp.json()?)
154}
155
156/// Check whether a token is valid by calling `GET /api/v4/user`.
157pub fn validate_token(
158    base_url: &str,
159    token: &str,
160) -> Result<bool, Box<dyn std::error::Error>> {
161    let mut headers = HeaderMap::new();
162    headers.insert(
163        HeaderName::from_static("private-token"),
164        HeaderValue::from_str(token)?,
165    );
166    let client = reqwest::blocking::Client::builder()
167        .user_agent("hs-relmon/0.2.0")
168        .default_headers(headers)
169        .build()?;
170    let url = format!(
171        "{}/api/v4/user",
172        base_url.trim_end_matches('/')
173    );
174    let resp = client.get(&url).send()?;
175    Ok(resp.status().is_success())
176}
177
178/// Parse a GitLab project URL into (base_url, project_path).
179///
180/// Example: `https://gitlab.com/CentOS/Hyperscale/rpms/perf`
181/// returns `("https://gitlab.com", "CentOS/Hyperscale/rpms/perf")`
182pub fn parse_project_url(url: &str) -> Result<(String, String), String> {
183    let url = url.trim_end_matches('/');
184    let rest = url
185        .strip_prefix("https://")
186        .or_else(|| url.strip_prefix("http://"))
187        .ok_or_else(|| format!("invalid GitLab URL: {url}"))?;
188
189    let slash = rest
190        .find('/')
191        .ok_or_else(|| format!("no project path in URL: {url}"))?;
192
193    let host = &rest[..slash];
194    let path = &rest[slash + 1..];
195
196    if path.is_empty() {
197        return Err(format!("no project path in URL: {url}"));
198    }
199
200    let scheme = if url.starts_with("https://") {
201        "https"
202    } else {
203        "http"
204    };
205    Ok((format!("{scheme}://{host}"), path.to_string()))
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211
212    #[test]
213    fn test_parse_project_url() {
214        let (base, path) = parse_project_url(
215            "https://gitlab.com/CentOS/Hyperscale/rpms/perf",
216        )
217        .unwrap();
218        assert_eq!(base, "https://gitlab.com");
219        assert_eq!(path, "CentOS/Hyperscale/rpms/perf");
220    }
221
222    #[test]
223    fn test_parse_project_url_trailing_slash() {
224        let (base, path) =
225            parse_project_url("https://gitlab.com/group/project/")
226                .unwrap();
227        assert_eq!(base, "https://gitlab.com");
228        assert_eq!(path, "group/project");
229    }
230
231    #[test]
232    fn test_parse_project_url_http() {
233        let (base, path) = parse_project_url(
234            "http://gitlab.example.com/group/project",
235        )
236        .unwrap();
237        assert_eq!(base, "http://gitlab.example.com");
238        assert_eq!(path, "group/project");
239    }
240
241    #[test]
242    fn test_parse_project_url_no_scheme() {
243        assert!(
244            parse_project_url("gitlab.com/group/project").is_err()
245        );
246    }
247
248    #[test]
249    fn test_parse_project_url_no_path() {
250        assert!(parse_project_url("https://gitlab.com/").is_err());
251        assert!(parse_project_url("https://gitlab.com").is_err());
252    }
253
254    #[test]
255    fn test_issues_url() {
256        let client = Client::new(
257            "https://gitlab.com",
258            "CentOS/Hyperscale/rpms/perf",
259            "fake-token",
260        )
261        .unwrap();
262        assert_eq!(
263            client.issues_url(),
264            "https://gitlab.com/api/v4/projects/CentOS%2FHyperscale%2Frpms%2Fperf/issues"
265        );
266    }
267
268    #[test]
269    fn test_issue_update_serialization() {
270        let update = IssueUpdate {
271            title: Some("new title".into()),
272            add_labels: Some("bug".into()),
273            ..Default::default()
274        };
275        let json = serde_json::to_value(&update).unwrap();
276        assert_eq!(json["title"], "new title");
277        assert_eq!(json["add_labels"], "bug");
278        // None fields should be absent
279        assert!(json.get("description").is_none());
280        assert!(json.get("state_event").is_none());
281    }
282
283    #[test]
284    fn test_issue_deserialize() {
285        let json = r#"{
286            "iid": 42,
287            "title": "Test issue",
288            "description": "Some description",
289            "state": "opened",
290            "web_url": "https://gitlab.com/group/project/-/issues/42"
291        }"#;
292        let issue: Issue = serde_json::from_str(json).unwrap();
293        assert_eq!(issue.iid, 42);
294        assert_eq!(issue.title, "Test issue");
295        assert_eq!(issue.description.as_deref(), Some("Some description"));
296        assert_eq!(issue.state, "opened");
297    }
298
299    #[test]
300    fn test_issue_deserialize_null_description() {
301        let json = r#"{
302            "iid": 1,
303            "title": "t",
304            "description": null,
305            "state": "opened",
306            "web_url": "u"
307        }"#;
308        let issue: Issue = serde_json::from_str(json).unwrap();
309        assert!(issue.description.is_none());
310    }
311}