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
31/// Load the GitLab token from `GITLAB_TOKEN` env var or config file.
32pub fn load_token() -> Result<String, Box<dyn std::error::Error>> {
33    let token = std::env::var("GITLAB_TOKEN").ok().or_else(|| {
34        crate::config::load()
35            .ok()
36            .and_then(|c| c.gitlab.map(|g| g.access_token))
37    });
38    token.ok_or_else(|| {
39        "GitLab token not found; set GITLAB_TOKEN \
40        or run 'hs-relmon config'"
41            .into()
42    })
43}
44
45/// Build an HTTP client with the given token.
46fn build_http_client(
47    token: &str,
48) -> Result<reqwest::blocking::Client, Box<dyn std::error::Error>> {
49    let mut headers = HeaderMap::new();
50    headers.insert(
51        HeaderName::from_static("private-token"),
52        HeaderValue::from_str(token)?,
53    );
54    Ok(reqwest::blocking::Client::builder()
55        .user_agent("hs-relmon/0.2.1")
56        .default_headers(headers)
57        .build()?)
58}
59
60impl Client {
61    /// Create a client for the given GitLab project URL.
62    ///
63    /// Reads the authentication token from `GITLAB_TOKEN`, falling
64    /// back to the config file.
65    pub fn from_project_url(
66        url: &str,
67    ) -> Result<Self, Box<dyn std::error::Error>> {
68        let token = load_token()?;
69        let (base_url, project_path) = parse_project_url(url)?;
70        Self::new(&base_url, &project_path, &token)
71    }
72
73    /// Create a client with explicit parameters.
74    pub fn new(
75        base_url: &str,
76        project_path: &str,
77        token: &str,
78    ) -> Result<Self, Box<dyn std::error::Error>> {
79        let http = build_http_client(token)?;
80        Ok(Self {
81            http,
82            base_url: base_url.trim_end_matches('/').to_string(),
83            project_path: project_path.to_string(),
84        })
85    }
86
87    /// Create a new issue.
88    pub fn create_issue(
89        &self,
90        title: &str,
91        description: Option<&str>,
92        labels: Option<&str>,
93    ) -> Result<Issue, Box<dyn std::error::Error>> {
94        let mut body = serde_json::json!({"title": title});
95        if let Some(desc) = description {
96            body["description"] = desc.into();
97        }
98        if let Some(labels) = labels {
99            body["labels"] = labels.into();
100        }
101
102        let resp = self.http.post(&self.issues_url()).json(&body).send()?;
103        check_response(resp)
104    }
105
106    /// List issues matching a label and optional state.
107    pub fn list_issues(
108        &self,
109        label: &str,
110        state: Option<&str>,
111    ) -> Result<Vec<Issue>, Box<dyn std::error::Error>> {
112        let mut query = vec![("labels", label)];
113        if let Some(s) = state {
114            query.push(("state", s));
115        }
116        let resp = self
117            .http
118            .get(&self.issues_url())
119            .query(&query)
120            .send()?;
121        if !resp.status().is_success() {
122            let status = resp.status();
123            let text = resp.text()?;
124            return Err(
125                format!("GitLab API error {status}: {text}").into(),
126            );
127        }
128        Ok(resp.json()?)
129    }
130
131    /// Edit an existing issue.
132    pub fn edit_issue(
133        &self,
134        iid: u64,
135        updates: &IssueUpdate,
136    ) -> Result<Issue, Box<dyn std::error::Error>> {
137        let body = serde_json::to_value(updates)?;
138        let resp = self
139            .http
140            .put(&format!("{}/{iid}", self.issues_url()))
141            .json(&body)
142            .send()?;
143        check_response(resp)
144    }
145
146    /// Fetch the work-item status for an issue via GraphQL.
147    ///
148    /// Returns the status name (e.g. "To do", "In progress")
149    /// or `None` if the work-item has no status widget.
150    pub fn get_work_item_status(
151        &self,
152        iid: u64,
153    ) -> Result<Option<String>, Box<dyn std::error::Error>>
154    {
155        let query = format!(
156            r#"{{ project(fullPath: "{}") {{
157                workItems(iids: ["{}"])  {{
158                    nodes {{ widgets {{
159                        type
160                        ... on WorkItemWidgetStatus {{
161                            status {{ name }}
162                        }}
163                    }} }}
164                }}
165            }} }}"#,
166            self.project_path, iid
167        );
168        let body = serde_json::json!({ "query": query });
169        let resp = self
170            .http
171            .post(&self.graphql_url())
172            .json(&body)
173            .send()?;
174        if !resp.status().is_success() {
175            let status = resp.status();
176            let text = resp.text()?;
177            return Err(format!(
178                "GitLab GraphQL error {status}: {text}"
179            )
180            .into());
181        }
182        let json: serde_json::Value = resp.json()?;
183        Ok(parse_work_item_status(&json))
184    }
185
186    fn issues_url(&self) -> String {
187        let encoded = self.project_path.replace('/', "%2F");
188        format!(
189            "{}/api/v4/projects/{}/issues",
190            self.base_url, encoded
191        )
192    }
193
194    fn graphql_url(&self) -> String {
195        format!("{}/api/graphql", self.base_url)
196    }
197}
198
199/// Extract the status name from a GraphQL work-item response.
200fn parse_work_item_status(
201    json: &serde_json::Value,
202) -> Option<String> {
203    json.pointer("/data/project/workItems/nodes/0/widgets")
204        .and_then(|w| w.as_array())
205        .and_then(|widgets| {
206            widgets.iter().find(|w| {
207                w.get("type").and_then(|t| t.as_str())
208                    == Some("STATUS")
209            })
210        })
211        .and_then(|w| w.pointer("/status/name"))
212        .and_then(|n| n.as_str())
213        .map(String::from)
214}
215
216/// Client for group-level GitLab API queries.
217pub struct GroupClient {
218    http: reqwest::blocking::Client,
219    base_url: String,
220    group_path: String,
221}
222
223impl GroupClient {
224    /// Create a group client from a GitLab group URL.
225    pub fn from_group_url(
226        url: &str,
227    ) -> Result<Self, Box<dyn std::error::Error>> {
228        let token = load_token()?;
229        let (base_url, group_path) = parse_project_url(url)?;
230        Self::new(&base_url, &group_path, &token)
231    }
232
233    /// Create a group client with explicit parameters.
234    pub fn new(
235        base_url: &str,
236        group_path: &str,
237        token: &str,
238    ) -> Result<Self, Box<dyn std::error::Error>> {
239        let http = build_http_client(token)?;
240        Ok(Self {
241            http,
242            base_url: base_url.trim_end_matches('/').to_string(),
243            group_path: group_path.to_string(),
244        })
245    }
246
247    /// List all issues in the group matching a label,
248    /// handling pagination automatically.
249    pub fn list_issues(
250        &self,
251        label: &str,
252        state: Option<&str>,
253    ) -> Result<Vec<Issue>, Box<dyn std::error::Error>> {
254        let mut all_issues = Vec::new();
255        let mut page = 1u32;
256        loop {
257            let page_str = page.to_string();
258            let mut query = vec![
259                ("labels", label),
260                ("per_page", "100"),
261                ("page", &page_str),
262            ];
263            if let Some(s) = state {
264                query.push(("state", s));
265            }
266            let resp = self
267                .http
268                .get(&self.issues_url())
269                .query(&query)
270                .send()?;
271            if !resp.status().is_success() {
272                let status = resp.status();
273                let text = resp.text()?;
274                return Err(format!(
275                    "GitLab API error {status}: {text}"
276                )
277                .into());
278            }
279            let next_page = resp
280                .headers()
281                .get("x-next-page")
282                .and_then(|v| v.to_str().ok())
283                .unwrap_or("")
284                .to_string();
285            let issues: Vec<Issue> = resp.json()?;
286            all_issues.extend(issues);
287            if next_page.is_empty() {
288                break;
289            }
290            page = next_page.parse()?;
291        }
292        Ok(all_issues)
293    }
294
295    /// Fetch the work-item status for an issue via GraphQL.
296    pub fn get_work_item_status(
297        &self,
298        project_path: &str,
299        iid: u64,
300    ) -> Result<Option<String>, Box<dyn std::error::Error>>
301    {
302        let query = format!(
303            r#"{{ project(fullPath: "{}") {{
304                workItems(iids: ["{}"])  {{
305                    nodes {{ widgets {{
306                        type
307                        ... on WorkItemWidgetStatus {{
308                            status {{ name }}
309                        }}
310                    }} }}
311                }}
312            }} }}"#,
313            project_path, iid
314        );
315        let body = serde_json::json!({ "query": query });
316        let resp = self
317            .http
318            .post(&self.graphql_url())
319            .json(&body)
320            .send()?;
321        if !resp.status().is_success() {
322            let status = resp.status();
323            let text = resp.text()?;
324            return Err(format!(
325                "GitLab GraphQL error {status}: {text}"
326            )
327            .into());
328        }
329        let json: serde_json::Value = resp.json()?;
330        Ok(parse_work_item_status(&json))
331    }
332
333    fn issues_url(&self) -> String {
334        let encoded = self.group_path.replace('/', "%2F");
335        format!(
336            "{}/api/v4/groups/{}/issues",
337            self.base_url, encoded
338        )
339    }
340
341    fn graphql_url(&self) -> String {
342        format!("{}/api/graphql", self.base_url)
343    }
344}
345
346/// Extract the package name from a GitLab issue web_url.
347///
348/// Example: `"https://gitlab.com/CentOS/Hyperscale/rpms/ethtool/-/issues/1"`
349/// returns `Some("ethtool")`.
350pub fn package_from_issue_url(web_url: &str) -> Option<&str> {
351    let project_part = web_url.split("/-/issues/").next()?;
352    let name = project_part.rsplit('/').next()?;
353    if name.is_empty() {
354        None
355    } else {
356        Some(name)
357    }
358}
359
360/// Extract the project path from a GitLab issue web_url.
361///
362/// Example: `"https://gitlab.com/CentOS/Hyperscale/rpms/ethtool/-/issues/1"`
363/// returns `Some("CentOS/Hyperscale/rpms/ethtool")`.
364pub fn project_path_from_issue_url(
365    web_url: &str,
366) -> Option<String> {
367    let project_part = web_url.split("/-/issues/").next()?;
368    let rest = project_part
369        .strip_prefix("https://")
370        .or_else(|| project_part.strip_prefix("http://"))?;
371    let slash = rest.find('/')?;
372    let path = &rest[slash + 1..];
373    if path.is_empty() {
374        None
375    } else {
376        Some(path.to_string())
377    }
378}
379
380/// Parameters for editing an issue.
381#[derive(Debug, Default, serde::Serialize)]
382pub struct IssueUpdate {
383    #[serde(skip_serializing_if = "Option::is_none")]
384    pub title: Option<String>,
385    #[serde(skip_serializing_if = "Option::is_none")]
386    pub description: Option<String>,
387    #[serde(skip_serializing_if = "Option::is_none")]
388    pub add_labels: Option<String>,
389    #[serde(skip_serializing_if = "Option::is_none")]
390    pub remove_labels: Option<String>,
391    #[serde(skip_serializing_if = "Option::is_none")]
392    pub state_event: Option<String>,
393}
394
395fn check_response(
396    resp: reqwest::blocking::Response,
397) -> Result<Issue, Box<dyn std::error::Error>> {
398    if !resp.status().is_success() {
399        let status = resp.status();
400        let text = resp.text()?;
401        return Err(format!("GitLab API error {status}: {text}").into());
402    }
403    Ok(resp.json()?)
404}
405
406/// Check whether a token is valid by calling `GET /api/v4/user`.
407pub fn validate_token(
408    base_url: &str,
409    token: &str,
410) -> Result<bool, Box<dyn std::error::Error>> {
411    let mut headers = HeaderMap::new();
412    headers.insert(
413        HeaderName::from_static("private-token"),
414        HeaderValue::from_str(token)?,
415    );
416    let client = reqwest::blocking::Client::builder()
417        .user_agent("hs-relmon/0.2.1")
418        .default_headers(headers)
419        .build()?;
420    let url = format!(
421        "{}/api/v4/user",
422        base_url.trim_end_matches('/')
423    );
424    let resp = client.get(&url).send()?;
425    Ok(resp.status().is_success())
426}
427
428/// Parse a GitLab project URL into (base_url, project_path).
429///
430/// Example: `https://gitlab.com/CentOS/Hyperscale/rpms/perf`
431/// returns `("https://gitlab.com", "CentOS/Hyperscale/rpms/perf")`
432pub fn parse_project_url(url: &str) -> Result<(String, String), String> {
433    let url = url.trim_end_matches('/');
434    let rest = url
435        .strip_prefix("https://")
436        .or_else(|| url.strip_prefix("http://"))
437        .ok_or_else(|| format!("invalid GitLab URL: {url}"))?;
438
439    let slash = rest
440        .find('/')
441        .ok_or_else(|| format!("no project path in URL: {url}"))?;
442
443    let host = &rest[..slash];
444    let path = &rest[slash + 1..];
445
446    if path.is_empty() {
447        return Err(format!("no project path in URL: {url}"));
448    }
449
450    let scheme = if url.starts_with("https://") {
451        "https"
452    } else {
453        "http"
454    };
455    Ok((format!("{scheme}://{host}"), path.to_string()))
456}
457
458#[cfg(test)]
459mod tests {
460    use super::*;
461
462    #[test]
463    fn test_parse_project_url() {
464        let (base, path) = parse_project_url(
465            "https://gitlab.com/CentOS/Hyperscale/rpms/perf",
466        )
467        .unwrap();
468        assert_eq!(base, "https://gitlab.com");
469        assert_eq!(path, "CentOS/Hyperscale/rpms/perf");
470    }
471
472    #[test]
473    fn test_parse_project_url_trailing_slash() {
474        let (base, path) =
475            parse_project_url("https://gitlab.com/group/project/")
476                .unwrap();
477        assert_eq!(base, "https://gitlab.com");
478        assert_eq!(path, "group/project");
479    }
480
481    #[test]
482    fn test_parse_project_url_http() {
483        let (base, path) = parse_project_url(
484            "http://gitlab.example.com/group/project",
485        )
486        .unwrap();
487        assert_eq!(base, "http://gitlab.example.com");
488        assert_eq!(path, "group/project");
489    }
490
491    #[test]
492    fn test_parse_project_url_no_scheme() {
493        assert!(
494            parse_project_url("gitlab.com/group/project").is_err()
495        );
496    }
497
498    #[test]
499    fn test_parse_project_url_no_path() {
500        assert!(parse_project_url("https://gitlab.com/").is_err());
501        assert!(parse_project_url("https://gitlab.com").is_err());
502    }
503
504    #[test]
505    fn test_issues_url() {
506        let client = Client::new(
507            "https://gitlab.com",
508            "CentOS/Hyperscale/rpms/perf",
509            "fake-token",
510        )
511        .unwrap();
512        assert_eq!(
513            client.issues_url(),
514            "https://gitlab.com/api/v4/projects/CentOS%2FHyperscale%2Frpms%2Fperf/issues"
515        );
516    }
517
518    #[test]
519    fn test_issue_update_serialization() {
520        let update = IssueUpdate {
521            title: Some("new title".into()),
522            add_labels: Some("bug".into()),
523            ..Default::default()
524        };
525        let json = serde_json::to_value(&update).unwrap();
526        assert_eq!(json["title"], "new title");
527        assert_eq!(json["add_labels"], "bug");
528        // None fields should be absent
529        assert!(json.get("description").is_none());
530        assert!(json.get("state_event").is_none());
531    }
532
533    #[test]
534    fn test_issue_deserialize() {
535        let json = r#"{
536            "iid": 42,
537            "title": "Test issue",
538            "description": "Some description",
539            "state": "opened",
540            "web_url": "https://gitlab.com/group/project/-/issues/42",
541            "assignees": [
542                {"username": "alice"},
543                {"username": "bob"}
544            ]
545        }"#;
546        let issue: Issue = serde_json::from_str(json).unwrap();
547        assert_eq!(issue.iid, 42);
548        assert_eq!(issue.title, "Test issue");
549        assert_eq!(issue.description.as_deref(), Some("Some description"));
550        assert_eq!(issue.state, "opened");
551        assert_eq!(issue.assignees.len(), 2);
552        assert_eq!(issue.assignees[0].username, "alice");
553        assert_eq!(issue.assignees[1].username, "bob");
554    }
555
556    #[test]
557    fn test_issue_deserialize_no_assignees() {
558        let json = r#"{
559            "iid": 1,
560            "title": "t",
561            "description": null,
562            "state": "opened",
563            "web_url": "u"
564        }"#;
565        let issue: Issue = serde_json::from_str(json).unwrap();
566        assert!(issue.description.is_none());
567        assert!(issue.assignees.is_empty());
568    }
569
570    #[test]
571    fn test_issue_deserialize_null_description() {
572        let json = r#"{
573            "iid": 1,
574            "title": "t",
575            "description": null,
576            "state": "opened",
577            "web_url": "u"
578        }"#;
579        let issue: Issue = serde_json::from_str(json).unwrap();
580        assert!(issue.description.is_none());
581    }
582
583    #[test]
584    fn test_graphql_url() {
585        let client = Client::new(
586            "https://gitlab.com",
587            "CentOS/Hyperscale/rpms/perf",
588            "fake-token",
589        )
590        .unwrap();
591        assert_eq!(
592            client.graphql_url(),
593            "https://gitlab.com/api/graphql"
594        );
595    }
596
597    #[test]
598    fn test_parse_work_item_status_found() {
599        let json: serde_json::Value = serde_json::from_str(
600            r#"{
601                "data": {
602                    "project": {
603                        "workItems": {
604                            "nodes": [{
605                                "widgets": [
606                                    { "type": "ASSIGNEES" },
607                                    {
608                                        "type": "STATUS",
609                                        "status": {
610                                            "name": "To do"
611                                        }
612                                    }
613                                ]
614                            }]
615                        }
616                    }
617                }
618            }"#,
619        )
620        .unwrap();
621        assert_eq!(
622            parse_work_item_status(&json).as_deref(),
623            Some("To do")
624        );
625    }
626
627    #[test]
628    fn test_parse_work_item_status_in_progress() {
629        let json: serde_json::Value = serde_json::from_str(
630            r#"{
631                "data": {
632                    "project": {
633                        "workItems": {
634                            "nodes": [{
635                                "widgets": [
636                                    {
637                                        "type": "STATUS",
638                                        "status": {
639                                            "name": "In progress"
640                                        }
641                                    }
642                                ]
643                            }]
644                        }
645                    }
646                }
647            }"#,
648        )
649        .unwrap();
650        assert_eq!(
651            parse_work_item_status(&json).as_deref(),
652            Some("In progress")
653        );
654    }
655
656    #[test]
657    fn test_parse_work_item_status_no_status_widget() {
658        let json: serde_json::Value = serde_json::from_str(
659            r#"{
660                "data": {
661                    "project": {
662                        "workItems": {
663                            "nodes": [{
664                                "widgets": [
665                                    { "type": "ASSIGNEES" },
666                                    { "type": "LABELS" }
667                                ]
668                            }]
669                        }
670                    }
671                }
672            }"#,
673        )
674        .unwrap();
675        assert!(parse_work_item_status(&json).is_none());
676    }
677
678    #[test]
679    fn test_parse_work_item_status_empty_nodes() {
680        let json: serde_json::Value = serde_json::from_str(
681            r#"{
682                "data": {
683                    "project": {
684                        "workItems": {
685                            "nodes": []
686                        }
687                    }
688                }
689            }"#,
690        )
691        .unwrap();
692        assert!(parse_work_item_status(&json).is_none());
693    }
694
695    #[test]
696    fn test_parse_work_item_status_null_status() {
697        let json: serde_json::Value = serde_json::from_str(
698            r#"{
699                "data": {
700                    "project": {
701                        "workItems": {
702                            "nodes": [{
703                                "widgets": [
704                                    {
705                                        "type": "STATUS",
706                                        "status": null
707                                    }
708                                ]
709                            }]
710                        }
711                    }
712                }
713            }"#,
714        )
715        .unwrap();
716        assert!(parse_work_item_status(&json).is_none());
717    }
718
719    #[test]
720    fn test_package_from_issue_url() {
721        assert_eq!(
722            package_from_issue_url(
723                "https://gitlab.com/CentOS/Hyperscale/\
724                rpms/ethtool/-/issues/1"
725            ),
726            Some("ethtool")
727        );
728        assert_eq!(
729            package_from_issue_url(
730                "https://gitlab.com/group/project/-/issues/42"
731            ),
732            Some("project")
733        );
734    }
735
736    #[test]
737    fn test_package_from_issue_url_no_issues_path() {
738        assert_eq!(
739            package_from_issue_url(
740                "https://gitlab.com/group/project"
741            ),
742            Some("project")
743        );
744    }
745
746    #[test]
747    fn test_package_from_issue_url_empty() {
748        assert_eq!(package_from_issue_url(""), None);
749    }
750
751    #[test]
752    fn test_project_path_from_issue_url() {
753        assert_eq!(
754            project_path_from_issue_url(
755                "https://gitlab.com/CentOS/Hyperscale/\
756                rpms/ethtool/-/issues/1"
757            )
758            .as_deref(),
759            Some("CentOS/Hyperscale/rpms/ethtool")
760        );
761    }
762
763    #[test]
764    fn test_project_path_from_issue_url_no_issues() {
765        assert_eq!(
766            project_path_from_issue_url(
767                "https://gitlab.com/group/project"
768            )
769            .as_deref(),
770            Some("group/project")
771        );
772    }
773
774    #[test]
775    fn test_project_path_from_issue_url_no_scheme() {
776        assert!(project_path_from_issue_url(
777            "gitlab.com/group/project"
778        )
779        .is_none());
780    }
781
782    #[test]
783    fn test_group_client_issues_url() {
784        let client = GroupClient::new(
785            "https://gitlab.com",
786            "CentOS/Hyperscale/rpms",
787            "fake-token",
788        )
789        .unwrap();
790        assert_eq!(
791            client.issues_url(),
792            "https://gitlab.com/api/v4/groups/\
793            CentOS%2FHyperscale%2Frpms/issues"
794        );
795    }
796
797    #[test]
798    fn test_group_client_graphql_url() {
799        let client = GroupClient::new(
800            "https://gitlab.com",
801            "CentOS/Hyperscale/rpms",
802            "fake-token",
803        )
804        .unwrap();
805        assert_eq!(
806            client.graphql_url(),
807            "https://gitlab.com/api/graphql"
808        );
809    }
810}