Skip to main content

sandogasa_gitlab/
lib.rs

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