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 user (assignee).
7#[derive(Debug, Deserialize)]
8pub struct Assignee {
9    pub username: String,
10}
11
12/// A GitLab issue.
13#[derive(Debug, Deserialize)]
14pub struct Issue {
15    pub iid: u64,
16    pub title: String,
17    pub description: Option<String>,
18    pub state: String,
19    pub web_url: String,
20    #[serde(default)]
21    pub assignees: Vec<Assignee>,
22}
23
24/// Client for the GitLab REST API v4.
25pub struct Client {
26    http: reqwest::blocking::Client,
27    base_url: String,
28    project_path: String,
29}
30
31impl Client {
32    /// Create a client for the given GitLab project URL.
33    ///
34    /// Reads the authentication token from `GITLAB_TOKEN`, falling
35    /// back to the config file.
36    pub fn from_project_url(
37        url: &str,
38    ) -> Result<Self, Box<dyn std::error::Error>> {
39        let token = std::env::var("GITLAB_TOKEN").ok().or_else(|| {
40            crate::config::load()
41                .ok()
42                .and_then(|c| c.gitlab.map(|g| g.access_token))
43        });
44        let token = token.ok_or(
45            "GitLab token not found; set GITLAB_TOKEN \
46            or run 'hs-relmon config'",
47        )?;
48        let (base_url, project_path) = parse_project_url(url)?;
49        Self::new(&base_url, &project_path, &token)
50    }
51
52    /// Create a client with explicit parameters.
53    pub fn new(
54        base_url: &str,
55        project_path: &str,
56        token: &str,
57    ) -> Result<Self, Box<dyn std::error::Error>> {
58        let mut headers = HeaderMap::new();
59        headers.insert(
60            HeaderName::from_static("private-token"),
61            HeaderValue::from_str(token)?,
62        );
63        let http = reqwest::blocking::Client::builder()
64            .user_agent("hs-relmon/0.2.1")
65            .default_headers(headers)
66            .build()?;
67        Ok(Self {
68            http,
69            base_url: base_url.trim_end_matches('/').to_string(),
70            project_path: project_path.to_string(),
71        })
72    }
73
74    /// Create a new issue.
75    pub fn create_issue(
76        &self,
77        title: &str,
78        description: Option<&str>,
79        labels: Option<&str>,
80    ) -> Result<Issue, Box<dyn std::error::Error>> {
81        let mut body = serde_json::json!({"title": title});
82        if let Some(desc) = description {
83            body["description"] = desc.into();
84        }
85        if let Some(labels) = labels {
86            body["labels"] = labels.into();
87        }
88
89        let resp = self.http.post(&self.issues_url()).json(&body).send()?;
90        check_response(resp)
91    }
92
93    /// List issues matching a label and optional state.
94    pub fn list_issues(
95        &self,
96        label: &str,
97        state: Option<&str>,
98    ) -> Result<Vec<Issue>, Box<dyn std::error::Error>> {
99        let mut query = vec![("labels", label)];
100        if let Some(s) = state {
101            query.push(("state", s));
102        }
103        let resp = self
104            .http
105            .get(&self.issues_url())
106            .query(&query)
107            .send()?;
108        if !resp.status().is_success() {
109            let status = resp.status();
110            let text = resp.text()?;
111            return Err(
112                format!("GitLab API error {status}: {text}").into(),
113            );
114        }
115        Ok(resp.json()?)
116    }
117
118    /// Edit an existing issue.
119    pub fn edit_issue(
120        &self,
121        iid: u64,
122        updates: &IssueUpdate,
123    ) -> Result<Issue, Box<dyn std::error::Error>> {
124        let body = serde_json::to_value(updates)?;
125        let resp = self
126            .http
127            .put(&format!("{}/{iid}", self.issues_url()))
128            .json(&body)
129            .send()?;
130        check_response(resp)
131    }
132
133    /// Fetch the work-item status for an issue via GraphQL.
134    ///
135    /// Returns the status name (e.g. "To do", "In progress")
136    /// or `None` if the work-item has no status widget.
137    pub fn get_work_item_status(
138        &self,
139        iid: u64,
140    ) -> Result<Option<String>, Box<dyn std::error::Error>>
141    {
142        let query = format!(
143            r#"{{ project(fullPath: "{}") {{
144                workItems(iids: ["{}"])  {{
145                    nodes {{ widgets {{
146                        type
147                        ... on WorkItemWidgetStatus {{
148                            status {{ name }}
149                        }}
150                    }} }}
151                }}
152            }} }}"#,
153            self.project_path, iid
154        );
155        let body = serde_json::json!({ "query": query });
156        let resp = self
157            .http
158            .post(&self.graphql_url())
159            .json(&body)
160            .send()?;
161        if !resp.status().is_success() {
162            let status = resp.status();
163            let text = resp.text()?;
164            return Err(format!(
165                "GitLab GraphQL error {status}: {text}"
166            )
167            .into());
168        }
169        let json: serde_json::Value = resp.json()?;
170        Ok(parse_work_item_status(&json))
171    }
172
173    fn issues_url(&self) -> String {
174        let encoded = self.project_path.replace('/', "%2F");
175        format!(
176            "{}/api/v4/projects/{}/issues",
177            self.base_url, encoded
178        )
179    }
180
181    fn graphql_url(&self) -> String {
182        format!("{}/api/graphql", self.base_url)
183    }
184}
185
186/// Extract the status name from a GraphQL work-item response.
187fn parse_work_item_status(
188    json: &serde_json::Value,
189) -> Option<String> {
190    json.pointer("/data/project/workItems/nodes/0/widgets")
191        .and_then(|w| w.as_array())
192        .and_then(|widgets| {
193            widgets.iter().find(|w| {
194                w.get("type").and_then(|t| t.as_str())
195                    == Some("STATUS")
196            })
197        })
198        .and_then(|w| w.pointer("/status/name"))
199        .and_then(|n| n.as_str())
200        .map(String::from)
201}
202
203/// Parameters for editing an issue.
204#[derive(Debug, Default, serde::Serialize)]
205pub struct IssueUpdate {
206    #[serde(skip_serializing_if = "Option::is_none")]
207    pub title: Option<String>,
208    #[serde(skip_serializing_if = "Option::is_none")]
209    pub description: Option<String>,
210    #[serde(skip_serializing_if = "Option::is_none")]
211    pub add_labels: Option<String>,
212    #[serde(skip_serializing_if = "Option::is_none")]
213    pub remove_labels: Option<String>,
214    #[serde(skip_serializing_if = "Option::is_none")]
215    pub state_event: Option<String>,
216}
217
218fn check_response(
219    resp: reqwest::blocking::Response,
220) -> Result<Issue, Box<dyn std::error::Error>> {
221    if !resp.status().is_success() {
222        let status = resp.status();
223        let text = resp.text()?;
224        return Err(format!("GitLab API error {status}: {text}").into());
225    }
226    Ok(resp.json()?)
227}
228
229/// Check whether a token is valid by calling `GET /api/v4/user`.
230pub fn validate_token(
231    base_url: &str,
232    token: &str,
233) -> Result<bool, Box<dyn std::error::Error>> {
234    let mut headers = HeaderMap::new();
235    headers.insert(
236        HeaderName::from_static("private-token"),
237        HeaderValue::from_str(token)?,
238    );
239    let client = reqwest::blocking::Client::builder()
240        .user_agent("hs-relmon/0.2.1")
241        .default_headers(headers)
242        .build()?;
243    let url = format!(
244        "{}/api/v4/user",
245        base_url.trim_end_matches('/')
246    );
247    let resp = client.get(&url).send()?;
248    Ok(resp.status().is_success())
249}
250
251/// Parse a GitLab project URL into (base_url, project_path).
252///
253/// Example: `https://gitlab.com/CentOS/Hyperscale/rpms/perf`
254/// returns `("https://gitlab.com", "CentOS/Hyperscale/rpms/perf")`
255pub fn parse_project_url(url: &str) -> Result<(String, String), String> {
256    let url = url.trim_end_matches('/');
257    let rest = url
258        .strip_prefix("https://")
259        .or_else(|| url.strip_prefix("http://"))
260        .ok_or_else(|| format!("invalid GitLab URL: {url}"))?;
261
262    let slash = rest
263        .find('/')
264        .ok_or_else(|| format!("no project path in URL: {url}"))?;
265
266    let host = &rest[..slash];
267    let path = &rest[slash + 1..];
268
269    if path.is_empty() {
270        return Err(format!("no project path in URL: {url}"));
271    }
272
273    let scheme = if url.starts_with("https://") {
274        "https"
275    } else {
276        "http"
277    };
278    Ok((format!("{scheme}://{host}"), path.to_string()))
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284
285    #[test]
286    fn test_parse_project_url() {
287        let (base, path) = parse_project_url(
288            "https://gitlab.com/CentOS/Hyperscale/rpms/perf",
289        )
290        .unwrap();
291        assert_eq!(base, "https://gitlab.com");
292        assert_eq!(path, "CentOS/Hyperscale/rpms/perf");
293    }
294
295    #[test]
296    fn test_parse_project_url_trailing_slash() {
297        let (base, path) =
298            parse_project_url("https://gitlab.com/group/project/")
299                .unwrap();
300        assert_eq!(base, "https://gitlab.com");
301        assert_eq!(path, "group/project");
302    }
303
304    #[test]
305    fn test_parse_project_url_http() {
306        let (base, path) = parse_project_url(
307            "http://gitlab.example.com/group/project",
308        )
309        .unwrap();
310        assert_eq!(base, "http://gitlab.example.com");
311        assert_eq!(path, "group/project");
312    }
313
314    #[test]
315    fn test_parse_project_url_no_scheme() {
316        assert!(
317            parse_project_url("gitlab.com/group/project").is_err()
318        );
319    }
320
321    #[test]
322    fn test_parse_project_url_no_path() {
323        assert!(parse_project_url("https://gitlab.com/").is_err());
324        assert!(parse_project_url("https://gitlab.com").is_err());
325    }
326
327    #[test]
328    fn test_issues_url() {
329        let client = Client::new(
330            "https://gitlab.com",
331            "CentOS/Hyperscale/rpms/perf",
332            "fake-token",
333        )
334        .unwrap();
335        assert_eq!(
336            client.issues_url(),
337            "https://gitlab.com/api/v4/projects/CentOS%2FHyperscale%2Frpms%2Fperf/issues"
338        );
339    }
340
341    #[test]
342    fn test_issue_update_serialization() {
343        let update = IssueUpdate {
344            title: Some("new title".into()),
345            add_labels: Some("bug".into()),
346            ..Default::default()
347        };
348        let json = serde_json::to_value(&update).unwrap();
349        assert_eq!(json["title"], "new title");
350        assert_eq!(json["add_labels"], "bug");
351        // None fields should be absent
352        assert!(json.get("description").is_none());
353        assert!(json.get("state_event").is_none());
354    }
355
356    #[test]
357    fn test_issue_deserialize() {
358        let json = r#"{
359            "iid": 42,
360            "title": "Test issue",
361            "description": "Some description",
362            "state": "opened",
363            "web_url": "https://gitlab.com/group/project/-/issues/42",
364            "assignees": [
365                {"username": "alice"},
366                {"username": "bob"}
367            ]
368        }"#;
369        let issue: Issue = serde_json::from_str(json).unwrap();
370        assert_eq!(issue.iid, 42);
371        assert_eq!(issue.title, "Test issue");
372        assert_eq!(issue.description.as_deref(), Some("Some description"));
373        assert_eq!(issue.state, "opened");
374        assert_eq!(issue.assignees.len(), 2);
375        assert_eq!(issue.assignees[0].username, "alice");
376        assert_eq!(issue.assignees[1].username, "bob");
377    }
378
379    #[test]
380    fn test_issue_deserialize_no_assignees() {
381        let json = r#"{
382            "iid": 1,
383            "title": "t",
384            "description": null,
385            "state": "opened",
386            "web_url": "u"
387        }"#;
388        let issue: Issue = serde_json::from_str(json).unwrap();
389        assert!(issue.description.is_none());
390        assert!(issue.assignees.is_empty());
391    }
392
393    #[test]
394    fn test_issue_deserialize_null_description() {
395        let json = r#"{
396            "iid": 1,
397            "title": "t",
398            "description": null,
399            "state": "opened",
400            "web_url": "u"
401        }"#;
402        let issue: Issue = serde_json::from_str(json).unwrap();
403        assert!(issue.description.is_none());
404    }
405
406    #[test]
407    fn test_graphql_url() {
408        let client = Client::new(
409            "https://gitlab.com",
410            "CentOS/Hyperscale/rpms/perf",
411            "fake-token",
412        )
413        .unwrap();
414        assert_eq!(
415            client.graphql_url(),
416            "https://gitlab.com/api/graphql"
417        );
418    }
419
420    #[test]
421    fn test_parse_work_item_status_found() {
422        let json: serde_json::Value = serde_json::from_str(
423            r#"{
424                "data": {
425                    "project": {
426                        "workItems": {
427                            "nodes": [{
428                                "widgets": [
429                                    { "type": "ASSIGNEES" },
430                                    {
431                                        "type": "STATUS",
432                                        "status": {
433                                            "name": "To do"
434                                        }
435                                    }
436                                ]
437                            }]
438                        }
439                    }
440                }
441            }"#,
442        )
443        .unwrap();
444        assert_eq!(
445            parse_work_item_status(&json).as_deref(),
446            Some("To do")
447        );
448    }
449
450    #[test]
451    fn test_parse_work_item_status_in_progress() {
452        let json: serde_json::Value = serde_json::from_str(
453            r#"{
454                "data": {
455                    "project": {
456                        "workItems": {
457                            "nodes": [{
458                                "widgets": [
459                                    {
460                                        "type": "STATUS",
461                                        "status": {
462                                            "name": "In progress"
463                                        }
464                                    }
465                                ]
466                            }]
467                        }
468                    }
469                }
470            }"#,
471        )
472        .unwrap();
473        assert_eq!(
474            parse_work_item_status(&json).as_deref(),
475            Some("In progress")
476        );
477    }
478
479    #[test]
480    fn test_parse_work_item_status_no_status_widget() {
481        let json: serde_json::Value = serde_json::from_str(
482            r#"{
483                "data": {
484                    "project": {
485                        "workItems": {
486                            "nodes": [{
487                                "widgets": [
488                                    { "type": "ASSIGNEES" },
489                                    { "type": "LABELS" }
490                                ]
491                            }]
492                        }
493                    }
494                }
495            }"#,
496        )
497        .unwrap();
498        assert!(parse_work_item_status(&json).is_none());
499    }
500
501    #[test]
502    fn test_parse_work_item_status_empty_nodes() {
503        let json: serde_json::Value = serde_json::from_str(
504            r#"{
505                "data": {
506                    "project": {
507                        "workItems": {
508                            "nodes": []
509                        }
510                    }
511                }
512            }"#,
513        )
514        .unwrap();
515        assert!(parse_work_item_status(&json).is_none());
516    }
517
518    #[test]
519    fn test_parse_work_item_status_null_status() {
520        let json: serde_json::Value = serde_json::from_str(
521            r#"{
522                "data": {
523                    "project": {
524                        "workItems": {
525                            "nodes": [{
526                                "widgets": [
527                                    {
528                                        "type": "STATUS",
529                                        "status": null
530                                    }
531                                ]
532                            }]
533                        }
534                    }
535                }
536            }"#,
537        )
538        .unwrap();
539        assert!(parse_work_item_status(&json).is_none());
540    }
541}