Skip to main content

sandogasa_gitlab/
lib.rs

1// SPDX-License-Identifier: Apache-2.0 OR MIT
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/// A project name returned by the GitLab group projects API.
495#[derive(Debug, Deserialize)]
496pub struct GroupProject {
497    pub name: String,
498    pub path: String,
499}
500
501/// List all projects under a GitLab group (public, no auth needed).
502///
503/// `group_url` is the full URL, e.g.
504/// `https://gitlab.com/CentOS/Hyperscale/rpms`.
505/// Paginates automatically and retries on 500/502/503/504.
506pub fn list_group_projects(
507    group_url: &str,
508) -> Result<Vec<GroupProject>, Box<dyn std::error::Error>> {
509    let (base_url, group_path) = parse_project_url(group_url)?;
510    let encoded = group_path.replace('/', "%2F");
511    let client = reqwest::blocking::Client::builder()
512        .user_agent("sandogasa-gitlab")
513        .build()?;
514    let mut all = Vec::new();
515    let mut page = 1u32;
516    loop {
517        let url = format!(
518            "{}/api/v4/groups/{}/projects?per_page=100&page={}&simple=true&include_subgroups=false",
519            base_url, encoded, page
520        );
521        eprint!("\r  fetching page {page}...");
522        let resp = get_with_retry_blocking(&client, &url)?;
523        let next_page = resp
524            .headers()
525            .get("x-next-page")
526            .and_then(|v| v.to_str().ok())
527            .unwrap_or("")
528            .to_string();
529        let projects: Vec<GroupProject> = resp.json()?;
530        all.extend(projects);
531        if next_page.is_empty() {
532            break;
533        }
534        page = next_page.parse()?;
535    }
536    eprintln!("\r  fetched {} project(s)", all.len());
537    Ok(all)
538}
539
540/// Blocking GET with retry on transient server errors.
541fn get_with_retry_blocking(
542    client: &reqwest::blocking::Client,
543    url: &str,
544) -> Result<reqwest::blocking::Response, Box<dyn std::error::Error>> {
545    let mut last_err = None;
546    for attempt in 0..=3u32 {
547        let resp = client.get(url).send()?;
548        let status = resp.status();
549        if status == reqwest::StatusCode::INTERNAL_SERVER_ERROR
550            || status == reqwest::StatusCode::BAD_GATEWAY
551            || status == reqwest::StatusCode::SERVICE_UNAVAILABLE
552            || status == reqwest::StatusCode::GATEWAY_TIMEOUT
553        {
554            let delay = std::time::Duration::from_secs(1 << attempt);
555            eprintln!(
556                "  {status}, retrying in {}s ({}/3)",
557                delay.as_secs(),
558                attempt + 1,
559            );
560            std::thread::sleep(delay);
561            last_err = Some(format!("{status} for {url}"));
562            continue;
563        }
564        if !resp.status().is_success() {
565            let text = resp.text()?;
566            return Err(format!("GitLab API error {status}: {text}").into());
567        }
568        return Ok(resp);
569    }
570    Err(last_err.unwrap().into())
571}
572
573/// Parse a GitLab project URL into (base_url, project_path).
574///
575/// Example: `https://gitlab.com/CentOS/Hyperscale/rpms/perf`
576/// returns `("https://gitlab.com", "CentOS/Hyperscale/rpms/perf")`
577pub fn parse_project_url(url: &str) -> Result<(String, String), String> {
578    let url = url.trim_end_matches('/');
579    let rest = url
580        .strip_prefix("https://")
581        .or_else(|| url.strip_prefix("http://"))
582        .ok_or_else(|| format!("invalid GitLab URL: {url}"))?;
583
584    let slash = rest
585        .find('/')
586        .ok_or_else(|| format!("no project path in URL: {url}"))?;
587
588    let host = &rest[..slash];
589    let path = &rest[slash + 1..];
590
591    if path.is_empty() {
592        return Err(format!("no project path in URL: {url}"));
593    }
594
595    let scheme = if url.starts_with("https://") {
596        "https"
597    } else {
598        "http"
599    };
600    Ok((format!("{scheme}://{host}"), path.to_string()))
601}
602
603#[cfg(test)]
604mod tests {
605    use super::*;
606
607    #[test]
608    fn test_parse_project_url() {
609        let (base, path) =
610            parse_project_url("https://gitlab.com/CentOS/Hyperscale/rpms/perf").unwrap();
611        assert_eq!(base, "https://gitlab.com");
612        assert_eq!(path, "CentOS/Hyperscale/rpms/perf");
613    }
614
615    #[test]
616    fn test_parse_project_url_trailing_slash() {
617        let (base, path) = parse_project_url("https://gitlab.com/group/project/").unwrap();
618        assert_eq!(base, "https://gitlab.com");
619        assert_eq!(path, "group/project");
620    }
621
622    #[test]
623    fn test_parse_project_url_http() {
624        let (base, path) = parse_project_url("http://gitlab.example.com/group/project").unwrap();
625        assert_eq!(base, "http://gitlab.example.com");
626        assert_eq!(path, "group/project");
627    }
628
629    #[test]
630    fn test_parse_project_url_no_scheme() {
631        assert!(parse_project_url("gitlab.com/group/project").is_err());
632    }
633
634    #[test]
635    fn test_parse_project_url_no_path() {
636        assert!(parse_project_url("https://gitlab.com/").is_err());
637        assert!(parse_project_url("https://gitlab.com").is_err());
638    }
639
640    #[test]
641    fn test_issues_url() {
642        let client = Client::new(
643            "https://gitlab.com",
644            "CentOS/Hyperscale/rpms/perf",
645            "fake-token",
646        )
647        .unwrap();
648        assert_eq!(
649            client.issues_url(),
650            "https://gitlab.com/api/v4/projects/CentOS%2FHyperscale%2Frpms%2Fperf/issues"
651        );
652    }
653
654    #[test]
655    fn test_issue_update_serialization() {
656        let update = IssueUpdate {
657            title: Some("new title".into()),
658            add_labels: Some("bug".into()),
659            ..Default::default()
660        };
661        let json = serde_json::to_value(&update).unwrap();
662        assert_eq!(json["title"], "new title");
663        assert_eq!(json["add_labels"], "bug");
664        assert!(json.get("description").is_none());
665        assert!(json.get("state_event").is_none());
666    }
667
668    #[test]
669    fn test_issue_deserialize() {
670        let json = r#"{
671            "iid": 42,
672            "title": "Test issue",
673            "description": "Some description",
674            "state": "opened",
675            "web_url": "https://gitlab.com/group/project/-/issues/42",
676            "assignees": [
677                {"username": "alice"},
678                {"username": "bob"}
679            ]
680        }"#;
681        let issue: Issue = serde_json::from_str(json).unwrap();
682        assert_eq!(issue.iid, 42);
683        assert_eq!(issue.title, "Test issue");
684        assert_eq!(issue.description.as_deref(), Some("Some description"));
685        assert_eq!(issue.state, "opened");
686        assert_eq!(issue.assignees.len(), 2);
687        assert_eq!(issue.assignees[0].username, "alice");
688        assert_eq!(issue.assignees[1].username, "bob");
689    }
690
691    #[test]
692    fn test_issue_deserialize_no_assignees() {
693        let json =
694            r#"{"iid": 1, "title": "t", "description": null, "state": "opened", "web_url": "u"}"#;
695        let issue: Issue = serde_json::from_str(json).unwrap();
696        assert!(issue.description.is_none());
697        assert!(issue.assignees.is_empty());
698    }
699
700    #[test]
701    fn test_graphql_url() {
702        let client = Client::new(
703            "https://gitlab.com",
704            "CentOS/Hyperscale/rpms/perf",
705            "fake-token",
706        )
707        .unwrap();
708        assert_eq!(client.graphql_url(), "https://gitlab.com/api/graphql");
709    }
710
711    #[test]
712    fn test_parse_work_item_status_found() {
713        let json: serde_json::Value = serde_json::from_str(
714            r#"{"data":{"project":{"workItems":{"nodes":[{"widgets":[{"type":"ASSIGNEES"},{"type":"STATUS","status":{"name":"To do"}}]}]}}}}"#,
715        ).unwrap();
716        assert_eq!(parse_work_item_status(&json).as_deref(), Some("To do"));
717    }
718
719    #[test]
720    fn test_parse_work_item_status_in_progress() {
721        let json: serde_json::Value = serde_json::from_str(
722            r#"{"data":{"project":{"workItems":{"nodes":[{"widgets":[{"type":"STATUS","status":{"name":"In progress"}}]}]}}}}"#,
723        ).unwrap();
724        assert_eq!(
725            parse_work_item_status(&json).as_deref(),
726            Some("In progress")
727        );
728    }
729
730    #[test]
731    fn test_parse_work_item_status_no_status_widget() {
732        let json: serde_json::Value = serde_json::from_str(
733            r#"{"data":{"project":{"workItems":{"nodes":[{"widgets":[{"type":"ASSIGNEES"},{"type":"LABELS"}]}]}}}}"#,
734        ).unwrap();
735        assert!(parse_work_item_status(&json).is_none());
736    }
737
738    #[test]
739    fn test_parse_work_item_status_empty_nodes() {
740        let json: serde_json::Value =
741            serde_json::from_str(r#"{"data":{"project":{"workItems":{"nodes":[]}}}}"#).unwrap();
742        assert!(parse_work_item_status(&json).is_none());
743    }
744
745    #[test]
746    fn test_parse_work_item_status_null_status() {
747        let json: serde_json::Value = serde_json::from_str(
748            r#"{"data":{"project":{"workItems":{"nodes":[{"widgets":[{"type":"STATUS","status":null}]}]}}}}"#,
749        ).unwrap();
750        assert!(parse_work_item_status(&json).is_none());
751    }
752
753    #[test]
754    fn test_package_from_issue_url() {
755        assert_eq!(
756            package_from_issue_url("https://gitlab.com/CentOS/Hyperscale/rpms/ethtool/-/issues/1"),
757            Some("ethtool")
758        );
759        assert_eq!(
760            package_from_issue_url("https://gitlab.com/group/project/-/issues/42"),
761            Some("project")
762        );
763    }
764
765    #[test]
766    fn test_package_from_issue_url_no_issues_path() {
767        assert_eq!(
768            package_from_issue_url("https://gitlab.com/group/project"),
769            Some("project")
770        );
771    }
772
773    #[test]
774    fn test_package_from_issue_url_empty() {
775        assert_eq!(package_from_issue_url(""), None);
776    }
777
778    #[test]
779    fn test_project_path_from_issue_url() {
780        assert_eq!(
781            project_path_from_issue_url(
782                "https://gitlab.com/CentOS/Hyperscale/rpms/ethtool/-/issues/1"
783            )
784            .as_deref(),
785            Some("CentOS/Hyperscale/rpms/ethtool")
786        );
787    }
788
789    #[test]
790    fn test_project_path_from_issue_url_no_issues() {
791        assert_eq!(
792            project_path_from_issue_url("https://gitlab.com/group/project").as_deref(),
793            Some("group/project")
794        );
795    }
796
797    #[test]
798    fn test_project_path_from_issue_url_no_scheme() {
799        assert!(project_path_from_issue_url("gitlab.com/group/project").is_none());
800    }
801
802    #[test]
803    fn test_parse_work_item_id_found() {
804        let json: serde_json::Value = serde_json::from_str(
805            r#"{"data":{"project":{"workItems":{"nodes":[{"id":"gid://gitlab/WorkItem/42"}]}}}}"#,
806        )
807        .unwrap();
808        assert_eq!(
809            parse_work_item_id(&json).as_deref(),
810            Some("gid://gitlab/WorkItem/42")
811        );
812    }
813
814    #[test]
815    fn test_parse_work_item_id_empty() {
816        let json: serde_json::Value =
817            serde_json::from_str(r#"{"data":{"project":{"workItems":{"nodes":[]}}}}"#).unwrap();
818        assert!(parse_work_item_id(&json).is_none());
819    }
820
821    #[test]
822    fn test_parse_mutation_errors_none() {
823        let json: serde_json::Value =
824            serde_json::from_str(r#"{"data":{"workItemUpdate":{"errors":[]}}}"#).unwrap();
825        assert!(parse_mutation_errors(&json).is_none());
826    }
827
828    #[test]
829    fn test_parse_mutation_errors_present() {
830        let json: serde_json::Value = serde_json::from_str(
831            r#"{"data":{"workItemUpdate":{"errors":["something went wrong"]}}}"#,
832        )
833        .unwrap();
834        let errors = parse_mutation_errors(&json).unwrap();
835        assert_eq!(errors, vec!["something went wrong"]);
836    }
837
838    #[test]
839    fn test_parse_status_id_found() {
840        let json: serde_json::Value = serde_json::from_str(
841            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"}]}]}]}}}}"#,
842        ).unwrap();
843        assert_eq!(
844            parse_status_id(&json, "In progress").as_deref(),
845            Some("gid://gitlab/WorkItems::Statuses::Custom::Status/2")
846        );
847    }
848
849    #[test]
850    fn test_parse_status_id_not_found() {
851        let json: serde_json::Value = serde_json::from_str(
852            r#"{"data":{"project":{"workItemTypes":{"nodes":[{"widgetDefinitions":[{"type":"STATUS","allowedStatuses":[{"id":"gid://id/1","name":"To do"}]}]}]}}}}"#,
853        ).unwrap();
854        assert!(parse_status_id(&json, "In progress").is_none());
855    }
856
857    #[test]
858    fn test_group_client_issues_url() {
859        let client =
860            GroupClient::new("https://gitlab.com", "CentOS/Hyperscale/rpms", "fake-token").unwrap();
861        assert_eq!(
862            client.issues_url(),
863            "https://gitlab.com/api/v4/groups/CentOS%2FHyperscale%2Frpms/issues"
864        );
865    }
866
867    #[test]
868    fn test_group_client_graphql_url() {
869        let client =
870            GroupClient::new("https://gitlab.com", "CentOS/Hyperscale/rpms", "fake-token").unwrap();
871        assert_eq!(client.graphql_url(), "https://gitlab.com/api/graphql");
872    }
873
874    #[test]
875    fn test_add_note_success() {
876        let mut server = mockito::Server::new();
877        let mock = server
878            .mock("POST", "/api/v4/projects/g%2Fp/issues/1/notes")
879            .match_header("private-token", "tok")
880            .match_body(mockito::Matcher::Json(serde_json::json!({"body": "hello"})))
881            .with_status(201)
882            .with_body("{}")
883            .create();
884        let client = Client::new(&server.url(), "g/p", "tok").unwrap();
885        client.add_note(1, "hello").unwrap();
886        mock.assert();
887    }
888
889    #[test]
890    fn test_add_note_error() {
891        let mut server = mockito::Server::new();
892        let mock = server
893            .mock("POST", "/api/v4/projects/g%2Fp/issues/1/notes")
894            .with_status(403)
895            .with_body("forbidden")
896            .create();
897        let client = Client::new(&server.url(), "g/p", "tok").unwrap();
898        let err = client.add_note(1, "x").unwrap_err();
899        assert!(err.to_string().contains("403"), "{}", err);
900        mock.assert();
901    }
902
903    #[test]
904    fn test_edit_issue_success() {
905        let mut server = mockito::Server::new();
906        let mock = server
907            .mock("PUT", "/api/v4/projects/g%2Fp/issues/5")
908            .match_header("private-token", "tok")
909            .with_status(200)
910            .with_header("content-type", "application/json")
911            .with_body(r#"{"iid":5,"title":"t","description":null,"state":"closed","web_url":"https://example.com/-/issues/5"}"#)
912            .create();
913        let client = Client::new(&server.url(), "g/p", "tok").unwrap();
914        let updates = IssueUpdate {
915            state_event: Some("close".into()),
916            ..Default::default()
917        };
918        let issue = client.edit_issue(5, &updates).unwrap();
919        assert_eq!(issue.state, "closed");
920        mock.assert();
921    }
922
923    #[test]
924    fn test_edit_issue_error() {
925        let mut server = mockito::Server::new();
926        let mock = server
927            .mock("PUT", "/api/v4/projects/g%2Fp/issues/5")
928            .with_status(404)
929            .with_body("not found")
930            .create();
931        let client = Client::new(&server.url(), "g/p", "tok").unwrap();
932        let updates = IssueUpdate::default();
933        let err = client.edit_issue(5, &updates).unwrap_err();
934        assert!(err.to_string().contains("404"), "{}", err);
935        mock.assert();
936    }
937
938    #[test]
939    fn test_create_issue_success() {
940        let mut server = mockito::Server::new();
941        let mock = server
942            .mock("POST", "/api/v4/projects/g%2Fp/issues")
943            .match_header("private-token", "tok")
944            .with_status(201)
945            .with_header("content-type", "application/json")
946            .with_body(r#"{"iid":10,"title":"new issue","description":"desc","state":"opened","web_url":"https://example.com/-/issues/10"}"#)
947            .create();
948        let client = Client::new(&server.url(), "g/p", "tok").unwrap();
949        let issue = client
950            .create_issue("new issue", Some("desc"), Some("bug"))
951            .unwrap();
952        assert_eq!(issue.iid, 10);
953        assert_eq!(issue.title, "new issue");
954        mock.assert();
955    }
956
957    #[test]
958    fn test_list_issues_success() {
959        let mut server = mockito::Server::new();
960        let mock = server
961            .mock("GET", "/api/v4/projects/g%2Fp/issues")
962            .match_query(mockito::Matcher::AllOf(vec![
963                mockito::Matcher::UrlEncoded("labels".into(), "relmon".into()),
964                mockito::Matcher::UrlEncoded("state".into(), "opened".into()),
965            ]))
966            .with_status(200)
967            .with_header("content-type", "application/json")
968            .with_body(
969                r#"[{"iid":1,"title":"t","description":null,"state":"opened","web_url":"u"}]"#,
970            )
971            .create();
972        let client = Client::new(&server.url(), "g/p", "tok").unwrap();
973        let issues = client.list_issues("relmon", Some("opened")).unwrap();
974        assert_eq!(issues.len(), 1);
975        assert_eq!(issues[0].iid, 1);
976        mock.assert();
977    }
978
979    #[test]
980    fn test_list_issues_error() {
981        let mut server = mockito::Server::new();
982        let mock = server
983            .mock("GET", "/api/v4/projects/g%2Fp/issues")
984            .match_query(mockito::Matcher::Any)
985            .with_status(500)
986            .with_body("internal error")
987            .create();
988        let client = Client::new(&server.url(), "g/p", "tok").unwrap();
989        let err = client.list_issues("relmon", None).unwrap_err();
990        assert!(err.to_string().contains("500"), "{}", err);
991        mock.assert();
992    }
993}