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, Serialize};
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    /// ISO-8601 date string (YYYY-MM-DD) or None.
25    #[serde(default)]
26    pub start_date: Option<String>,
27    /// ISO-8601 date string (YYYY-MM-DD) or None.
28    #[serde(default)]
29    pub due_date: Option<String>,
30    /// ISO-8601 timestamp GitLab set when the issue was
31    /// created. Useful as a fallback start-date when the
32    /// underlying Koji build has been untagged and we can't
33    /// recover its creation time.
34    #[serde(default)]
35    pub created_at: Option<String>,
36}
37
38/// A GitLab merge request (minimal fields).
39#[derive(Debug, Deserialize)]
40pub struct MergeRequest {
41    pub iid: u64,
42    pub title: String,
43    #[serde(default)]
44    pub description: Option<String>,
45    pub state: String,
46    pub web_url: String,
47    pub source_branch: String,
48    pub target_branch: String,
49}
50
51/// Client for the GitLab REST API v4.
52pub struct Client {
53    http: reqwest::blocking::Client,
54    base_url: String,
55    project_path: String,
56}
57
58/// Build an HTTP client with the given token.
59fn build_http_client(token: &str) -> Result<reqwest::blocking::Client, Box<dyn std::error::Error>> {
60    let mut headers = HeaderMap::new();
61    headers.insert(
62        HeaderName::from_static("private-token"),
63        HeaderValue::from_str(token)?,
64    );
65    Ok(reqwest::blocking::Client::builder()
66        .user_agent("sandogasa-gitlab/0.6.2")
67        .default_headers(headers)
68        .build()?)
69}
70
71impl Client {
72    /// Create a client from a GitLab project URL and token.
73    pub fn from_project_url(url: &str, token: &str) -> Result<Self, Box<dyn std::error::Error>> {
74        let (base_url, project_path) = parse_project_url(url)?;
75        Self::new(&base_url, &project_path, token)
76    }
77
78    /// Create a client with explicit parameters.
79    pub fn new(
80        base_url: &str,
81        project_path: &str,
82        token: &str,
83    ) -> Result<Self, Box<dyn std::error::Error>> {
84        sandogasa_cli::ensure_secure_url(base_url)?;
85        let http = build_http_client(token)?;
86        Ok(Self {
87            http,
88            base_url: base_url.trim_end_matches('/').to_string(),
89            project_path: project_path.to_string(),
90        })
91    }
92
93    /// Fetch a merge request by its internal ID (iid).
94    pub fn merge_request(&self, iid: u64) -> Result<MergeRequest, Box<dyn std::error::Error>> {
95        let encoded = self.project_path.replace('/', "%2F");
96        let url = format!(
97            "{}/api/v4/projects/{}/merge_requests/{}",
98            self.base_url, encoded, iid
99        );
100        let resp = self.http.get(&url).send()?;
101        if !resp.status().is_success() {
102            let status = resp.status();
103            let text = resp.text()?;
104            return Err(format!("GitLab GET {url} failed: {status}: {text}").into());
105        }
106        Ok(resp.json()?)
107    }
108
109    /// Fetch a single issue by its internal ID (iid).
110    pub fn issue(&self, iid: u64) -> Result<Issue, Box<dyn std::error::Error>> {
111        let encoded = self.project_path.replace('/', "%2F");
112        let url = format!(
113            "{}/api/v4/projects/{}/issues/{}",
114            self.base_url, encoded, iid
115        );
116        let resp = self.http.get(&url).send()?;
117        if !resp.status().is_success() {
118            let status = resp.status();
119            let text = resp.text()?;
120            return Err(format!("GitLab GET {url} failed: {status}: {text}").into());
121        }
122        Ok(resp.json()?)
123    }
124
125    /// Create a new issue.
126    pub fn create_issue(
127        &self,
128        title: &str,
129        description: Option<&str>,
130        labels: Option<&str>,
131    ) -> Result<Issue, Box<dyn std::error::Error>> {
132        let mut body = serde_json::json!({"title": title});
133        if let Some(desc) = description {
134            body["description"] = desc.into();
135        }
136        if let Some(labels) = labels {
137            body["labels"] = labels.into();
138        }
139
140        let resp = self.http.post(self.issues_url()).json(&body).send()?;
141        check_response(resp)
142    }
143
144    /// List issues matching a label and optional state.
145    pub fn list_issues(
146        &self,
147        label: &str,
148        state: Option<&str>,
149    ) -> Result<Vec<Issue>, Box<dyn std::error::Error>> {
150        let mut query = vec![("labels", label)];
151        if let Some(s) = state {
152            query.push(("state", s));
153        }
154        let resp = self.http.get(self.issues_url()).query(&query).send()?;
155        if !resp.status().is_success() {
156            let status = resp.status();
157            let text = resp.text()?;
158            return Err(format!("GitLab API error {status}: {text}").into());
159        }
160        Ok(resp.json()?)
161    }
162
163    /// Add a note (comment) to an issue.
164    pub fn add_note(&self, iid: u64, body: &str) -> Result<(), Box<dyn std::error::Error>> {
165        let payload = serde_json::json!({ "body": body });
166        let resp = self
167            .http
168            .post(format!("{}/{iid}/notes", self.issues_url()))
169            .json(&payload)
170            .send()?;
171        if !resp.status().is_success() {
172            let status = resp.status();
173            let text = resp.text()?;
174            return Err(format!("GitLab API error {status}: {text}").into());
175        }
176        Ok(())
177    }
178
179    /// Edit an existing issue.
180    pub fn edit_issue(
181        &self,
182        iid: u64,
183        updates: &IssueUpdate,
184    ) -> Result<Issue, Box<dyn std::error::Error>> {
185        let body = serde_json::to_value(updates)?;
186        let resp = self
187            .http
188            .put(format!("{}/{iid}", self.issues_url()))
189            .json(&body)
190            .send()?;
191        check_response(resp)
192    }
193
194    /// Fetch the work-item status for an issue via GraphQL.
195    ///
196    /// Returns the status name (e.g. "To do", "In progress")
197    /// or `None` if the work-item has no status widget.
198    pub fn get_work_item_status(
199        &self,
200        iid: u64,
201    ) -> Result<Option<String>, Box<dyn std::error::Error>> {
202        let query = format!(
203            r#"{{ project(fullPath: "{}") {{
204                workItems(iids: ["{}"])  {{
205                    nodes {{ widgets {{
206                        type
207                        ... on WorkItemWidgetStatus {{
208                            status {{ name }}
209                        }}
210                    }} }}
211                }}
212            }} }}"#,
213            self.project_path, iid
214        );
215        let body = serde_json::json!({ "query": query });
216        let resp = self.http.post(self.graphql_url()).json(&body).send()?;
217        if !resp.status().is_success() {
218            let status = resp.status();
219            let text = resp.text()?;
220            return Err(format!("GitLab GraphQL error {status}: {text}").into());
221        }
222        let json: serde_json::Value = resp.json()?;
223        Ok(parse_work_item_status(&json))
224    }
225
226    /// Set the start / due dates on a work item via GraphQL.
227    ///
228    /// GitLab's REST `PUT /issues/:iid` endpoint silently
229    /// ignores `start_date` and doesn't reliably honor
230    /// `due_date` for work items, so date updates go through
231    /// the `workItemUpdate` mutation's `startAndDueDateWidget`.
232    /// Passing `None` for a field leaves it unchanged; passing
233    /// `Some("")` clears it.
234    pub fn set_work_item_dates(
235        &self,
236        iid: u64,
237        start_date: Option<&str>,
238        due_date: Option<&str>,
239    ) -> Result<(), Box<dyn std::error::Error>> {
240        if start_date.is_none() && due_date.is_none() {
241            return Ok(());
242        }
243        let work_item_id = self.get_work_item_id(iid)?;
244        let mut widget_fields: Vec<String> = Vec::new();
245        if let Some(sd) = start_date {
246            widget_fields.push(format!(r#"startDate: "{sd}""#));
247        }
248        if let Some(dd) = due_date {
249            widget_fields.push(format!(r#"dueDate: "{dd}""#));
250        }
251        let query = format!(
252            r#"mutation {{
253                workItemUpdate(input: {{
254                    id: "{work_item_id}"
255                    startAndDueDateWidget: {{ {} }}
256                }}) {{
257                    errors
258                }}
259            }}"#,
260            widget_fields.join(" "),
261        );
262        let body = serde_json::json!({ "query": query });
263        let resp = self.http.post(self.graphql_url()).json(&body).send()?;
264        if !resp.status().is_success() {
265            let http_status = resp.status();
266            let text = resp.text()?;
267            return Err(format!("GitLab GraphQL error {http_status}: {text}").into());
268        }
269        let json: serde_json::Value = resp.json()?;
270        if let Some(errors) = parse_mutation_errors(&json) {
271            return Err(format!("workItemUpdate errors: {errors:?}").into());
272        }
273        Ok(())
274    }
275
276    /// Set the work-item status for an issue via GraphQL.
277    ///
278    /// Resolves `status` (e.g. "In progress") to its Global ID
279    /// by querying the project's allowed statuses, then sends a
280    /// `workItemUpdate` mutation.
281    pub fn set_work_item_status(
282        &self,
283        iid: u64,
284        status: &str,
285    ) -> Result<(), Box<dyn std::error::Error>> {
286        let work_item_id = self.get_work_item_id(iid)?;
287        let status_id = self.resolve_status_id(status)?;
288        let query = format!(
289            r#"mutation {{
290                workItemUpdate(input: {{
291                    id: "{work_item_id}"
292                    statusWidget: {{ status: "{status_id}" }}
293                }}) {{
294                    errors
295                }}
296            }}"#,
297        );
298        let body = serde_json::json!({ "query": query });
299        let resp = self.http.post(self.graphql_url()).json(&body).send()?;
300        if !resp.status().is_success() {
301            let http_status = resp.status();
302            let text = resp.text()?;
303            return Err(format!("GitLab GraphQL error {http_status}: {text}").into());
304        }
305        let json: serde_json::Value = resp.json()?;
306        if let Some(errors) = parse_mutation_errors(&json) {
307            return Err(format!("workItemUpdate errors: {errors:?}").into());
308        }
309        Ok(())
310    }
311
312    /// Fetch the global ID of a work item by IID.
313    fn get_work_item_id(&self, iid: u64) -> Result<String, Box<dyn std::error::Error>> {
314        let query = format!(
315            r#"{{ project(fullPath: "{}") {{
316                workItems(iids: ["{}"])  {{
317                    nodes {{ id }}
318                }}
319            }} }}"#,
320            self.project_path, iid
321        );
322        let body = serde_json::json!({ "query": query });
323        let resp = self.http.post(self.graphql_url()).json(&body).send()?;
324        if !resp.status().is_success() {
325            let status = resp.status();
326            let text = resp.text()?;
327            return Err(format!("GitLab GraphQL error {status}: {text}").into());
328        }
329        let json: serde_json::Value = resp.json()?;
330        parse_work_item_id(&json).ok_or_else(|| "work item not found".into())
331    }
332
333    /// Resolve a status name to its Global ID.
334    fn resolve_status_id(&self, name: &str) -> Result<String, Box<dyn std::error::Error>> {
335        let query = format!(
336            r#"{{ project(fullPath: "{}") {{
337                workItemTypes(name: ISSUE) {{
338                    nodes {{
339                        widgetDefinitions {{
340                            type
341                            ... on WorkItemWidgetDefinitionStatus {{
342                                allowedStatuses {{ id name }}
343                            }}
344                        }}
345                    }}
346                }}
347            }} }}"#,
348            self.project_path
349        );
350        let body = serde_json::json!({ "query": query });
351        let resp = self.http.post(self.graphql_url()).json(&body).send()?;
352        if !resp.status().is_success() {
353            let http_status = resp.status();
354            let text = resp.text()?;
355            return Err(format!("GitLab GraphQL error {http_status}: {text}").into());
356        }
357        let json: serde_json::Value = resp.json()?;
358        parse_status_id(&json, name)
359            .ok_or_else(|| format!("status {name:?} not found in project").into())
360    }
361
362    fn issues_url(&self) -> String {
363        let encoded = self.project_path.replace('/', "%2F");
364        format!("{}/api/v4/projects/{}/issues", self.base_url, encoded)
365    }
366
367    fn graphql_url(&self) -> String {
368        format!("{}/api/graphql", self.base_url)
369    }
370}
371
372/// Extract the status name from a GraphQL work-item response.
373fn parse_work_item_status(json: &serde_json::Value) -> Option<String> {
374    json.pointer("/data/project/workItems/nodes/0/widgets")
375        .and_then(|w| w.as_array())
376        .and_then(|widgets| {
377            widgets
378                .iter()
379                .find(|w| w.get("type").and_then(|t| t.as_str()) == Some("STATUS"))
380        })
381        .and_then(|w| w.pointer("/status/name"))
382        .and_then(|n| n.as_str())
383        .map(String::from)
384}
385
386/// Extract the global ID from a GraphQL work-item response.
387fn parse_work_item_id(json: &serde_json::Value) -> Option<String> {
388    json.pointer("/data/project/workItems/nodes/0/id")
389        .and_then(|v| v.as_str())
390        .map(String::from)
391}
392
393/// Extract mutation errors from a workItemUpdate response.
394fn parse_mutation_errors(json: &serde_json::Value) -> Option<Vec<String>> {
395    let errors = json.pointer("/data/workItemUpdate/errors")?.as_array()?;
396    if errors.is_empty() {
397        return None;
398    }
399    Some(
400        errors
401            .iter()
402            .filter_map(|e| e.as_str().map(String::from))
403            .collect(),
404    )
405}
406
407/// Find the Global ID of a status by name from an `allowedStatuses` GraphQL response.
408fn parse_status_id(json: &serde_json::Value, name: &str) -> Option<String> {
409    let types = json
410        .pointer("/data/project/workItemTypes/nodes")?
411        .as_array()?;
412    for work_item_type in types {
413        let defs = work_item_type.get("widgetDefinitions")?.as_array()?;
414        for def in defs {
415            if def.get("type").and_then(|t| t.as_str()) != Some("STATUS") {
416                continue;
417            }
418            let statuses = def.get("allowedStatuses")?.as_array()?;
419            for status in statuses {
420                if status.get("name").and_then(|n| n.as_str()) == Some(name) {
421                    return status.get("id").and_then(|v| v.as_str()).map(String::from);
422                }
423            }
424        }
425    }
426    None
427}
428
429/// Client for group-level GitLab API queries.
430pub struct GroupClient {
431    http: reqwest::blocking::Client,
432    base_url: String,
433    group_path: String,
434}
435
436impl GroupClient {
437    /// Create a group client from a GitLab group URL and token.
438    pub fn from_group_url(url: &str, token: &str) -> Result<Self, Box<dyn std::error::Error>> {
439        let (base_url, group_path) = parse_project_url(url)?;
440        Self::new(&base_url, &group_path, token)
441    }
442
443    /// Create a group client with explicit parameters.
444    pub fn new(
445        base_url: &str,
446        group_path: &str,
447        token: &str,
448    ) -> Result<Self, Box<dyn std::error::Error>> {
449        sandogasa_cli::ensure_secure_url(base_url)?;
450        let http = build_http_client(token)?;
451        Ok(Self {
452            http,
453            base_url: base_url.trim_end_matches('/').to_string(),
454            group_path: group_path.to_string(),
455        })
456    }
457
458    /// List all issues in the group matching a label,
459    /// handling pagination automatically.
460    pub fn list_issues(
461        &self,
462        label: &str,
463        state: Option<&str>,
464    ) -> Result<Vec<Issue>, Box<dyn std::error::Error>> {
465        let mut all_issues = Vec::new();
466        let mut page = 1u32;
467        loop {
468            let page_str = page.to_string();
469            let mut query = vec![("labels", label), ("per_page", "100"), ("page", &page_str)];
470            if let Some(s) = state {
471                query.push(("state", s));
472            }
473            let resp = self.http.get(self.issues_url()).query(&query).send()?;
474            if !resp.status().is_success() {
475                let status = resp.status();
476                let text = resp.text()?;
477                return Err(format!("GitLab API error {status}: {text}").into());
478            }
479            let next_page = resp
480                .headers()
481                .get("x-next-page")
482                .and_then(|v| v.to_str().ok())
483                .unwrap_or("")
484                .to_string();
485            let issues: Vec<Issue> = resp.json()?;
486            all_issues.extend(issues);
487            if next_page.is_empty() {
488                break;
489            }
490            page = next_page.parse()?;
491        }
492        Ok(all_issues)
493    }
494
495    /// Fetch the work-item status for an issue via GraphQL.
496    pub fn get_work_item_status(
497        &self,
498        project_path: &str,
499        iid: u64,
500    ) -> Result<Option<String>, Box<dyn std::error::Error>> {
501        let query = format!(
502            r#"{{ project(fullPath: "{}") {{
503                workItems(iids: ["{}"])  {{
504                    nodes {{ widgets {{
505                        type
506                        ... on WorkItemWidgetStatus {{
507                            status {{ name }}
508                        }}
509                    }} }}
510                }}
511            }} }}"#,
512            project_path, iid
513        );
514        let body = serde_json::json!({ "query": query });
515        let resp = self.http.post(self.graphql_url()).json(&body).send()?;
516        if !resp.status().is_success() {
517            let status = resp.status();
518            let text = resp.text()?;
519            return Err(format!("GitLab GraphQL error {status}: {text}").into());
520        }
521        let json: serde_json::Value = resp.json()?;
522        Ok(parse_work_item_status(&json))
523    }
524
525    fn issues_url(&self) -> String {
526        let encoded = self.group_path.replace('/', "%2F");
527        format!("{}/api/v4/groups/{}/issues", self.base_url, encoded)
528    }
529
530    fn graphql_url(&self) -> String {
531        format!("{}/api/graphql", self.base_url)
532    }
533}
534
535/// Split a GitLab issue or work item URL just before the
536/// `/-/issues/<n>` or `/-/work_items/<n>` tail, returning the
537/// project portion. When neither separator is present, returns
538/// the input unchanged so callers that pass bare project URLs
539/// still get a useful result.
540fn project_part_of_issue_url(web_url: &str) -> &str {
541    for sep in ["/-/issues/", "/-/work_items/"] {
542        if let Some(idx) = web_url.find(sep) {
543            return &web_url[..idx];
544        }
545    }
546    web_url
547}
548
549/// Extract the package name from a GitLab issue or work item
550/// web_url.
551///
552/// Example: `"https://gitlab.com/CentOS/Hyperscale/rpms/ethtool/-/issues/1"`
553/// returns `Some("ethtool")`. Also accepts the work-items form
554/// `"...ethtool/-/work_items/1"`.
555pub fn package_from_issue_url(web_url: &str) -> Option<&str> {
556    let project_part = project_part_of_issue_url(web_url);
557    let name = project_part.rsplit('/').next()?;
558    if name.is_empty() { None } else { Some(name) }
559}
560
561/// Extract the project path from a GitLab issue or work item
562/// web_url.
563///
564/// Example: `"https://gitlab.com/CentOS/Hyperscale/rpms/ethtool/-/issues/1"`
565/// returns `Some("CentOS/Hyperscale/rpms/ethtool")`. Also
566/// accepts the work-items form.
567pub fn project_path_from_issue_url(web_url: &str) -> Option<String> {
568    let project_part = project_part_of_issue_url(web_url);
569    let rest = project_part
570        .strip_prefix("https://")
571        .or_else(|| project_part.strip_prefix("http://"))?;
572    let slash = rest.find('/')?;
573    let path = &rest[slash + 1..];
574    if path.is_empty() {
575        None
576    } else {
577        Some(path.to_string())
578    }
579}
580
581/// Parameters for editing an issue.
582#[derive(Debug, Default, serde::Serialize)]
583pub struct IssueUpdate {
584    #[serde(skip_serializing_if = "Option::is_none")]
585    pub title: Option<String>,
586    #[serde(skip_serializing_if = "Option::is_none")]
587    pub description: Option<String>,
588    #[serde(skip_serializing_if = "Option::is_none")]
589    pub add_labels: Option<String>,
590    #[serde(skip_serializing_if = "Option::is_none")]
591    pub remove_labels: Option<String>,
592    #[serde(skip_serializing_if = "Option::is_none")]
593    pub state_event: Option<String>,
594    /// ISO-8601 date string (YYYY-MM-DD). GitLab stores this
595    /// on the issue as its start date.
596    #[serde(skip_serializing_if = "Option::is_none")]
597    pub start_date: Option<String>,
598    /// ISO-8601 date string (YYYY-MM-DD). GitLab stores this
599    /// on the issue as its due date.
600    #[serde(skip_serializing_if = "Option::is_none")]
601    pub due_date: Option<String>,
602}
603
604fn check_response(resp: reqwest::blocking::Response) -> Result<Issue, Box<dyn std::error::Error>> {
605    if !resp.status().is_success() {
606        let status = resp.status();
607        let text = resp.text()?;
608        return Err(format!("GitLab API error {status}: {text}").into());
609    }
610    Ok(resp.json()?)
611}
612
613/// Check whether a token is valid by calling `GET /api/v4/user`.
614pub fn validate_token(base_url: &str, token: &str) -> Result<bool, Box<dyn std::error::Error>> {
615    sandogasa_cli::ensure_secure_url(base_url)?;
616    let mut headers = HeaderMap::new();
617    headers.insert(
618        HeaderName::from_static("private-token"),
619        HeaderValue::from_str(token)?,
620    );
621    let client = reqwest::blocking::Client::builder()
622        .user_agent("sandogasa-gitlab/0.6.2")
623        .default_headers(headers)
624        .build()?;
625    let url = format!("{}/api/v4/user", base_url.trim_end_matches('/'));
626    let resp = client.get(&url).send()?;
627    Ok(resp.status().is_success())
628}
629
630/// A project name returned by the GitLab group projects API.
631#[derive(Debug, Deserialize)]
632pub struct GroupProject {
633    pub name: String,
634    pub path: String,
635}
636
637/// List all projects under a GitLab group (public, no auth needed).
638///
639/// `group_url` is the full URL, e.g.
640/// `https://gitlab.com/CentOS/Hyperscale/rpms`.
641/// Paginates automatically and retries on 500/502/503/504.
642pub fn list_group_projects(
643    group_url: &str,
644) -> Result<Vec<GroupProject>, Box<dyn std::error::Error>> {
645    let (base_url, group_path) = parse_project_url(group_url)?;
646    let encoded = group_path.replace('/', "%2F");
647    let client = reqwest::blocking::Client::builder()
648        .user_agent("sandogasa-gitlab")
649        .build()?;
650    let mut all = Vec::new();
651    let mut page = 1u32;
652    loop {
653        let url = format!(
654            "{}/api/v4/groups/{}/projects?per_page=100&page={}&simple=true&include_subgroups=false",
655            base_url, encoded, page
656        );
657        eprint!("\r  fetching page {page}...");
658        let resp = get_with_retry_blocking(&client, &url)?;
659        let next_page = resp
660            .headers()
661            .get("x-next-page")
662            .and_then(|v| v.to_str().ok())
663            .unwrap_or("")
664            .to_string();
665        let projects: Vec<GroupProject> = resp.json()?;
666        all.extend(projects);
667        if next_page.is_empty() {
668            break;
669        }
670        page = next_page.parse()?;
671    }
672    eprintln!("\r  fetched {} project(s)", all.len());
673    Ok(all)
674}
675
676/// Blocking GET with retry on transient server errors.
677fn get_with_retry_blocking(
678    client: &reqwest::blocking::Client,
679    url: &str,
680) -> Result<reqwest::blocking::Response, Box<dyn std::error::Error>> {
681    let mut last_err = None;
682    for attempt in 0..=3u32 {
683        let resp = client.get(url).send()?;
684        let status = resp.status();
685        if status == reqwest::StatusCode::INTERNAL_SERVER_ERROR
686            || status == reqwest::StatusCode::BAD_GATEWAY
687            || status == reqwest::StatusCode::SERVICE_UNAVAILABLE
688            || status == reqwest::StatusCode::GATEWAY_TIMEOUT
689        {
690            let delay = std::time::Duration::from_secs(1 << attempt);
691            eprintln!(
692                "  {status}, retrying in {}s ({}/3)",
693                delay.as_secs(),
694                attempt + 1,
695            );
696            std::thread::sleep(delay);
697            last_err = Some(format!("{status} for {url}"));
698            continue;
699        }
700        if !resp.status().is_success() {
701            let text = resp.text()?;
702            return Err(format!("GitLab API error {status}: {text}").into());
703        }
704        return Ok(resp);
705    }
706    Err(last_err.unwrap().into())
707}
708
709/// Parse a GitLab project URL into (base_url, project_path).
710///
711/// Example: `https://gitlab.com/CentOS/Hyperscale/rpms/perf`
712/// returns `("https://gitlab.com", "CentOS/Hyperscale/rpms/perf")`
713pub fn parse_project_url(url: &str) -> Result<(String, String), String> {
714    let url = url.trim_end_matches('/');
715    let rest = url
716        .strip_prefix("https://")
717        .or_else(|| url.strip_prefix("http://"))
718        .ok_or_else(|| format!("invalid GitLab URL: {url}"))?;
719
720    let slash = rest
721        .find('/')
722        .ok_or_else(|| format!("no project path in URL: {url}"))?;
723
724    let host = &rest[..slash];
725    let path = &rest[slash + 1..];
726
727    if path.is_empty() {
728        return Err(format!("no project path in URL: {url}"));
729    }
730
731    let scheme = if url.starts_with("https://") {
732        "https"
733    } else {
734        "http"
735    };
736    Ok((format!("{scheme}://{host}"), path.to_string()))
737}
738
739/// Parse a merge request URL into its components.
740///
741/// Example:
742/// `https://gitlab.com/redhat/centos-stream/rpms/xz/-/merge_requests/42`
743/// returns `("https://gitlab.com", "redhat/centos-stream/rpms/xz", 42)`.
744pub fn parse_mr_url(url: &str) -> Result<(String, String, u64), String> {
745    let trimmed = url.trim_end_matches('/');
746    let rest = trimmed
747        .strip_prefix("https://")
748        .or_else(|| trimmed.strip_prefix("http://"))
749        .ok_or_else(|| format!("invalid GitLab URL: {url}"))?;
750    let slash = rest
751        .find('/')
752        .ok_or_else(|| format!("no project path in URL: {url}"))?;
753    let host = &rest[..slash];
754    let path = &rest[slash + 1..];
755
756    let scheme = if trimmed.starts_with("https://") {
757        "https"
758    } else {
759        "http"
760    };
761
762    let (project, iid_str) = path
763        .rsplit_once("/-/merge_requests/")
764        .ok_or_else(|| format!("not a merge request URL: {url}"))?;
765    // `iid_str` may have trailing query or fragment; strip them.
766    let iid_str = iid_str.split(['?', '#']).next().unwrap_or(iid_str);
767    let iid: u64 = iid_str
768        .parse()
769        .map_err(|_| format!("invalid merge request IID in URL: {url}"))?;
770
771    if project.is_empty() {
772        return Err(format!("no project path in URL: {url}"));
773    }
774
775    Ok((format!("{scheme}://{host}"), project.to_string(), iid))
776}
777
778/// Parse a GitLab issue / work-item URL into its components.
779///
780/// Accepts both the legacy `/-/issues/<n>` path and the newer
781/// `/-/work_items/<n>` form. Example:
782/// `https://gitlab.com/CentOS/proposed_updates/rpms/xz/-/work_items/1`
783/// returns `("https://gitlab.com", "CentOS/proposed_updates/rpms/xz", 1)`.
784pub fn parse_issue_url(url: &str) -> Result<(String, String, u64), String> {
785    let trimmed = url.trim_end_matches('/');
786    let rest = trimmed
787        .strip_prefix("https://")
788        .or_else(|| trimmed.strip_prefix("http://"))
789        .ok_or_else(|| format!("invalid GitLab URL: {url}"))?;
790    let slash = rest
791        .find('/')
792        .ok_or_else(|| format!("no project path in URL: {url}"))?;
793    let host = &rest[..slash];
794    let path = &rest[slash + 1..];
795
796    let scheme = if trimmed.starts_with("https://") {
797        "https"
798    } else {
799        "http"
800    };
801
802    let (project, iid_str) = path
803        .rsplit_once("/-/issues/")
804        .or_else(|| path.rsplit_once("/-/work_items/"))
805        .ok_or_else(|| format!("not an issue or work-item URL: {url}"))?;
806    let iid_str = iid_str.split(['?', '#']).next().unwrap_or(iid_str);
807    let iid: u64 = iid_str
808        .parse()
809        .map_err(|_| format!("invalid issue IID in URL: {url}"))?;
810
811    if project.is_empty() {
812        return Err(format!("no project path in URL: {url}"));
813    }
814
815    Ok((format!("{scheme}://{host}"), project.to_string(), iid))
816}
817
818/// A GitLab user as returned by `/users?username=<name>`.
819#[derive(Debug, Clone, Deserialize, Serialize)]
820pub struct User {
821    pub id: u64,
822    pub username: String,
823}
824
825/// Look up a user by username on a specific GitLab instance.
826/// Returns `Ok(None)` if the server returns 200 with an empty list
827/// (no user with that name on that instance).
828pub fn user_by_username(
829    base_url: &str,
830    token: &str,
831    username: &str,
832) -> Result<Option<User>, Box<dyn std::error::Error>> {
833    let http = build_http_client(token)?;
834    let url = format!("{}/api/v4/users", base_url.trim_end_matches('/'));
835    let resp = http.get(&url).query(&[("username", username)]).send()?;
836    if !resp.status().is_success() {
837        let status = resp.status();
838        let text = resp.text()?;
839        return Err(format!("GitLab GET {url} failed: {status}: {text}").into());
840    }
841    let users: Vec<User> = resp.json()?;
842    Ok(users.into_iter().next())
843}
844
845/// One entry from the user-activity events endpoint. Fields are
846/// sparse — GitLab only populates the ones relevant to each
847/// `action_name`.
848#[derive(Debug, Clone, Deserialize, Serialize)]
849pub struct Event {
850    pub id: u64,
851    pub project_id: u64,
852    pub action_name: String,
853    #[serde(default)]
854    pub target_type: Option<String>,
855    #[serde(default)]
856    pub target_iid: Option<u64>,
857    #[serde(default)]
858    pub target_title: Option<String>,
859    pub created_at: String,
860    #[serde(default)]
861    pub note: Option<EventNote>,
862    #[serde(default)]
863    pub push_data: Option<EventPushData>,
864}
865
866/// Note payload attached to `commented on` events.
867#[derive(Debug, Clone, Deserialize, Serialize)]
868pub struct EventNote {
869    #[serde(default)]
870    pub noteable_type: Option<String>,
871    #[serde(default)]
872    pub noteable_iid: Option<u64>,
873    #[serde(default)]
874    pub body: Option<String>,
875}
876
877/// Push payload attached to `pushed to` / `pushed new` events.
878#[derive(Debug, Clone, Deserialize, Serialize)]
879pub struct EventPushData {
880    #[serde(default)]
881    pub commit_count: u64,
882    #[serde(default)]
883    pub action: Option<String>,
884    #[serde(default)]
885    pub ref_type: Option<String>,
886    #[serde(default, rename = "ref")]
887    pub ref_name: Option<String>,
888    #[serde(default)]
889    pub commit_title: Option<String>,
890}
891
892/// Fetch a user's activity events within `[after, before)` — GitLab's
893/// event endpoint is half-open on both sides and rejects both-null.
894/// Results are paginated at 100/page; this follows every page until
895/// a short page arrives.
896///
897/// Callers that want events on a closed `[since, until]` day range
898/// should pass `after = since - 1` and `before = until + 1`, since
899/// events ON the boundary day are excluded by GitLab.
900pub fn user_events(
901    base_url: &str,
902    token: &str,
903    user_id: u64,
904    action: Option<&str>,
905    after: chrono::NaiveDate,
906    before: chrono::NaiveDate,
907) -> Result<Vec<Event>, Box<dyn std::error::Error>> {
908    let http = build_http_client(token)?;
909    let endpoint = format!(
910        "{}/api/v4/users/{}/events",
911        base_url.trim_end_matches('/'),
912        user_id
913    );
914    let after_str = after.to_string();
915    let before_str = before.to_string();
916    let mut out: Vec<Event> = Vec::new();
917    let mut page = 1u32;
918    loop {
919        let page_str = page.to_string();
920        let mut query: Vec<(&str, &str)> = vec![
921            ("per_page", "100"),
922            ("page", &page_str),
923            ("after", &after_str),
924            ("before", &before_str),
925        ];
926        if let Some(a) = action {
927            query.push(("action", a));
928        }
929        let resp = http.get(&endpoint).query(&query).send()?;
930        if !resp.status().is_success() {
931            let status = resp.status();
932            let text = resp.text()?;
933            return Err(format!("GitLab GET {endpoint} failed: {status}: {text}").into());
934        }
935        let batch: Vec<Event> = resp.json()?;
936        let n = batch.len();
937        out.extend(batch);
938        if n < 100 {
939            break;
940        }
941        page += 1;
942    }
943    Ok(out)
944}
945
946/// Minimal project identity: what you need to filter events by
947/// `path_with_namespace` prefix and render a human-readable link.
948#[derive(Debug, Clone, Deserialize, Serialize)]
949pub struct ProjectSummary {
950    pub id: u64,
951    pub path_with_namespace: String,
952    pub web_url: String,
953}
954
955/// Look up a project's `path_with_namespace` from its numeric ID.
956/// Used to map event `project_id` → group-prefix filter.
957pub fn project_summary(
958    base_url: &str,
959    token: &str,
960    project_id: u64,
961) -> Result<ProjectSummary, Box<dyn std::error::Error>> {
962    let http = build_http_client(token)?;
963    let url = format!(
964        "{}/api/v4/projects/{}",
965        base_url.trim_end_matches('/'),
966        project_id
967    );
968    let resp = http.get(&url).send()?;
969    if !resp.status().is_success() {
970        let status = resp.status();
971        let text = resp.text()?;
972        return Err(format!("GitLab GET {url} failed: {status}: {text}").into());
973    }
974    Ok(resp.json()?)
975}
976
977/// Count commits in `project_id` authored by `author` within
978/// `[since, until]` (inclusive). GitLab's commits endpoint
979/// matches the `author` parameter against both name and email
980/// fields, so passing the GitLab username usually works; if the
981/// user authored commits under an email only, pass that email
982/// string instead.
983///
984/// Intended as a cross-check against the push-event count: a big
985/// gap (pushed >> authored) flags mirror activity — the user
986/// pushed commits they didn't author.
987pub fn count_authored_commits(
988    base_url: &str,
989    token: &str,
990    project_id: u64,
991    author: &str,
992    since: chrono::NaiveDate,
993    until: chrono::NaiveDate,
994) -> Result<u64, Box<dyn std::error::Error>> {
995    let http = build_http_client(token)?;
996    let endpoint = format!(
997        "{}/api/v4/projects/{}/repository/commits",
998        base_url.trim_end_matches('/'),
999        project_id
1000    );
1001    // Both bounds are inclusive on this endpoint (unlike the
1002    // events endpoint), so pass the days as-is.
1003    let since_str = format!("{since}T00:00:00Z");
1004    let until_str = format!("{until}T23:59:59Z");
1005    let mut total: u64 = 0;
1006    let mut page = 1u32;
1007    loop {
1008        let page_str = page.to_string();
1009        let query: Vec<(&str, &str)> = vec![
1010            ("per_page", "100"),
1011            ("page", &page_str),
1012            ("author", author),
1013            ("since", &since_str),
1014            ("until", &until_str),
1015        ];
1016        let resp = http.get(&endpoint).query(&query).send()?;
1017        if !resp.status().is_success() {
1018            let status = resp.status();
1019            let text = resp.text()?;
1020            return Err(format!("GitLab GET {endpoint} failed: {status}: {text}").into());
1021        }
1022        // The endpoint returns an array; we only need the length
1023        // so decode as a generic array and count.
1024        let batch: Vec<serde_json::Value> = resp.json()?;
1025        let n = batch.len() as u64;
1026        total += n;
1027        if n < 100 {
1028            break;
1029        }
1030        page += 1;
1031    }
1032    Ok(total)
1033}
1034
1035/// A tag as returned by `/projects/:id/repository/tags`. The
1036/// fields kept here are the ones relevant to an activity-window
1037/// match: the tag's `created_at` (when the ref was pushed,
1038/// distinct from the commit date) and the tag name.
1039#[derive(Debug, Clone, Deserialize, Serialize)]
1040pub struct Tag {
1041    pub name: String,
1042    /// Tag-ref creation timestamp on GitLab (ISO 8601). This is
1043    /// when the tag was pushed, *not* when the underlying commit
1044    /// was authored — so a tag created locally weeks ago and
1045    /// pushed today appears here with today's date.
1046    pub created_at: String,
1047}
1048
1049/// List tags for a project, paginated. Returns an empty list on
1050/// 404. Uses `order_by=updated&sort=desc` so newer tags come
1051/// first — handy when the caller wants to short-circuit on the
1052/// first tag older than its window of interest.
1053pub fn list_tags(
1054    base_url: &str,
1055    token: &str,
1056    project_id: u64,
1057) -> Result<Vec<Tag>, Box<dyn std::error::Error>> {
1058    let http = build_http_client(token)?;
1059    let endpoint = format!(
1060        "{}/api/v4/projects/{}/repository/tags",
1061        base_url.trim_end_matches('/'),
1062        project_id
1063    );
1064    let mut out: Vec<Tag> = Vec::new();
1065    let mut page = 1u32;
1066    loop {
1067        let page_str = page.to_string();
1068        let query: Vec<(&str, &str)> = vec![
1069            ("per_page", "100"),
1070            ("page", &page_str),
1071            ("order_by", "updated"),
1072            ("sort", "desc"),
1073        ];
1074        let resp = http.get(&endpoint).query(&query).send()?;
1075        if resp.status().as_u16() == 404 {
1076            break;
1077        }
1078        if !resp.status().is_success() {
1079            let status = resp.status();
1080            let text = resp.text()?;
1081            return Err(format!("GitLab GET {endpoint} failed: {status}: {text}").into());
1082        }
1083        let batch: Vec<Tag> = resp.json()?;
1084        let n = batch.len();
1085        out.extend(batch);
1086        if n < 100 {
1087            break;
1088        }
1089        page += 1;
1090    }
1091    Ok(out)
1092}
1093
1094/// A GitLab Release as returned by `/projects/:id/releases`.
1095/// Field selection follows the API's snake_case names.
1096#[derive(Debug, Clone, Deserialize, Serialize)]
1097pub struct Release {
1098    pub tag_name: String,
1099    #[serde(default)]
1100    pub name: Option<String>,
1101    #[serde(default)]
1102    pub description: Option<String>,
1103    pub released_at: String,
1104    pub author: ReleaseAuthor,
1105    #[serde(default, rename = "_links")]
1106    pub links: Option<ReleaseLinks>,
1107    #[serde(default)]
1108    pub upcoming_release: bool,
1109}
1110
1111/// Author block on a Release. Username is the field most useful
1112/// for cross-referencing with the calling user's profile.
1113#[derive(Debug, Clone, Deserialize, Serialize)]
1114pub struct ReleaseAuthor {
1115    pub id: u64,
1116    pub username: String,
1117    #[serde(default)]
1118    pub name: Option<String>,
1119}
1120
1121/// `_links` block. The `self` link is the canonical web URL of
1122/// the release page.
1123#[derive(Debug, Clone, Deserialize, Serialize)]
1124pub struct ReleaseLinks {
1125    #[serde(default, rename = "self")]
1126    pub self_url: Option<String>,
1127}
1128
1129/// List releases for a project. Returns an empty list on 404
1130/// (project gone) so callers can iterate over many projects
1131/// without per-project error handling.
1132pub fn project_releases(
1133    base_url: &str,
1134    token: &str,
1135    project_id: u64,
1136) -> Result<Vec<Release>, Box<dyn std::error::Error>> {
1137    let http = build_http_client(token)?;
1138    let endpoint = format!(
1139        "{}/api/v4/projects/{}/releases",
1140        base_url.trim_end_matches('/'),
1141        project_id
1142    );
1143    let mut out: Vec<Release> = Vec::new();
1144    let mut page = 1u32;
1145    loop {
1146        let page_str = page.to_string();
1147        let query: Vec<(&str, &str)> = vec![("per_page", "100"), ("page", &page_str)];
1148        let resp = http.get(&endpoint).query(&query).send()?;
1149        if resp.status().as_u16() == 404 {
1150            break;
1151        }
1152        if !resp.status().is_success() {
1153            let status = resp.status();
1154            let text = resp.text()?;
1155            return Err(format!("GitLab GET {endpoint} failed: {status}: {text}").into());
1156        }
1157        let batch: Vec<Release> = resp.json()?;
1158        let n = batch.len();
1159        out.extend(batch);
1160        if n < 100 {
1161            break;
1162        }
1163        page += 1;
1164    }
1165    Ok(out)
1166}
1167
1168#[cfg(test)]
1169mod tests {
1170    use super::*;
1171
1172    #[test]
1173    fn new_rejects_plaintext_remote() {
1174        // A token must not be sent to a plaintext http non-loopback URL.
1175        assert!(Client::new("http://gitlab.example.com", "g/p", "tok").is_err());
1176        assert!(GroupClient::new("http://gitlab.example.com", "g", "tok").is_err());
1177    }
1178
1179    #[test]
1180    fn test_parse_project_url() {
1181        let (base, path) =
1182            parse_project_url("https://gitlab.com/CentOS/Hyperscale/rpms/perf").unwrap();
1183        assert_eq!(base, "https://gitlab.com");
1184        assert_eq!(path, "CentOS/Hyperscale/rpms/perf");
1185    }
1186
1187    #[test]
1188    fn test_parse_project_url_trailing_slash() {
1189        let (base, path) = parse_project_url("https://gitlab.com/group/project/").unwrap();
1190        assert_eq!(base, "https://gitlab.com");
1191        assert_eq!(path, "group/project");
1192    }
1193
1194    #[test]
1195    fn test_parse_project_url_http() {
1196        let (base, path) = parse_project_url("http://gitlab.example.com/group/project").unwrap();
1197        assert_eq!(base, "http://gitlab.example.com");
1198        assert_eq!(path, "group/project");
1199    }
1200
1201    #[test]
1202    fn test_parse_project_url_no_scheme() {
1203        assert!(parse_project_url("gitlab.com/group/project").is_err());
1204    }
1205
1206    #[test]
1207    fn test_parse_project_url_no_path() {
1208        assert!(parse_project_url("https://gitlab.com/").is_err());
1209        assert!(parse_project_url("https://gitlab.com").is_err());
1210    }
1211
1212    #[test]
1213    fn test_issues_url() {
1214        let client = Client::new(
1215            "https://gitlab.com",
1216            "CentOS/Hyperscale/rpms/perf",
1217            "fake-token",
1218        )
1219        .unwrap();
1220        assert_eq!(
1221            client.issues_url(),
1222            "https://gitlab.com/api/v4/projects/CentOS%2FHyperscale%2Frpms%2Fperf/issues"
1223        );
1224    }
1225
1226    #[test]
1227    fn test_issue_update_serialization() {
1228        let update = IssueUpdate {
1229            title: Some("new title".into()),
1230            add_labels: Some("bug".into()),
1231            ..Default::default()
1232        };
1233        let json = serde_json::to_value(&update).unwrap();
1234        assert_eq!(json["title"], "new title");
1235        assert_eq!(json["add_labels"], "bug");
1236        assert!(json.get("description").is_none());
1237        assert!(json.get("state_event").is_none());
1238    }
1239
1240    #[test]
1241    fn test_issue_deserialize() {
1242        let json = r#"{
1243            "iid": 42,
1244            "title": "Test issue",
1245            "description": "Some description",
1246            "state": "opened",
1247            "web_url": "https://gitlab.com/group/project/-/issues/42",
1248            "assignees": [
1249                {"username": "alice"},
1250                {"username": "bob"}
1251            ]
1252        }"#;
1253        let issue: Issue = serde_json::from_str(json).unwrap();
1254        assert_eq!(issue.iid, 42);
1255        assert_eq!(issue.title, "Test issue");
1256        assert_eq!(issue.description.as_deref(), Some("Some description"));
1257        assert_eq!(issue.state, "opened");
1258        assert_eq!(issue.assignees.len(), 2);
1259        assert_eq!(issue.assignees[0].username, "alice");
1260        assert_eq!(issue.assignees[1].username, "bob");
1261    }
1262
1263    #[test]
1264    fn test_issue_deserialize_no_assignees() {
1265        let json =
1266            r#"{"iid": 1, "title": "t", "description": null, "state": "opened", "web_url": "u"}"#;
1267        let issue: Issue = serde_json::from_str(json).unwrap();
1268        assert!(issue.description.is_none());
1269        assert!(issue.assignees.is_empty());
1270    }
1271
1272    #[test]
1273    fn test_graphql_url() {
1274        let client = Client::new(
1275            "https://gitlab.com",
1276            "CentOS/Hyperscale/rpms/perf",
1277            "fake-token",
1278        )
1279        .unwrap();
1280        assert_eq!(client.graphql_url(), "https://gitlab.com/api/graphql");
1281    }
1282
1283    #[test]
1284    fn test_parse_work_item_status_found() {
1285        let json: serde_json::Value = serde_json::from_str(
1286            r#"{"data":{"project":{"workItems":{"nodes":[{"widgets":[{"type":"ASSIGNEES"},{"type":"STATUS","status":{"name":"To do"}}]}]}}}}"#,
1287        ).unwrap();
1288        assert_eq!(parse_work_item_status(&json).as_deref(), Some("To do"));
1289    }
1290
1291    #[test]
1292    fn test_parse_work_item_status_in_progress() {
1293        let json: serde_json::Value = serde_json::from_str(
1294            r#"{"data":{"project":{"workItems":{"nodes":[{"widgets":[{"type":"STATUS","status":{"name":"In progress"}}]}]}}}}"#,
1295        ).unwrap();
1296        assert_eq!(
1297            parse_work_item_status(&json).as_deref(),
1298            Some("In progress")
1299        );
1300    }
1301
1302    #[test]
1303    fn test_parse_work_item_status_no_status_widget() {
1304        let json: serde_json::Value = serde_json::from_str(
1305            r#"{"data":{"project":{"workItems":{"nodes":[{"widgets":[{"type":"ASSIGNEES"},{"type":"LABELS"}]}]}}}}"#,
1306        ).unwrap();
1307        assert!(parse_work_item_status(&json).is_none());
1308    }
1309
1310    #[test]
1311    fn test_parse_work_item_status_empty_nodes() {
1312        let json: serde_json::Value =
1313            serde_json::from_str(r#"{"data":{"project":{"workItems":{"nodes":[]}}}}"#).unwrap();
1314        assert!(parse_work_item_status(&json).is_none());
1315    }
1316
1317    #[test]
1318    fn test_parse_work_item_status_null_status() {
1319        let json: serde_json::Value = serde_json::from_str(
1320            r#"{"data":{"project":{"workItems":{"nodes":[{"widgets":[{"type":"STATUS","status":null}]}]}}}}"#,
1321        ).unwrap();
1322        assert!(parse_work_item_status(&json).is_none());
1323    }
1324
1325    #[test]
1326    fn test_package_from_issue_url() {
1327        assert_eq!(
1328            package_from_issue_url("https://gitlab.com/CentOS/Hyperscale/rpms/ethtool/-/issues/1"),
1329            Some("ethtool")
1330        );
1331        assert_eq!(
1332            package_from_issue_url("https://gitlab.com/group/project/-/issues/42"),
1333            Some("project")
1334        );
1335    }
1336
1337    #[test]
1338    fn test_package_from_issue_url_no_issues_path() {
1339        assert_eq!(
1340            package_from_issue_url("https://gitlab.com/group/project"),
1341            Some("project")
1342        );
1343    }
1344
1345    #[test]
1346    fn test_package_from_issue_url_empty() {
1347        assert_eq!(package_from_issue_url(""), None);
1348    }
1349
1350    #[test]
1351    fn test_package_from_issue_url_work_items_form() {
1352        assert_eq!(
1353            package_from_issue_url(
1354                "https://gitlab.com/CentOS/proposed_updates/rpms/PackageKit/-/work_items/1"
1355            ),
1356            Some("PackageKit"),
1357        );
1358    }
1359
1360    #[test]
1361    fn test_project_path_from_issue_url_work_items_form() {
1362        assert_eq!(
1363            project_path_from_issue_url(
1364                "https://gitlab.com/CentOS/proposed_updates/rpms/PackageKit/-/work_items/1"
1365            )
1366            .as_deref(),
1367            Some("CentOS/proposed_updates/rpms/PackageKit"),
1368        );
1369    }
1370
1371    #[test]
1372    fn test_project_path_from_issue_url() {
1373        assert_eq!(
1374            project_path_from_issue_url(
1375                "https://gitlab.com/CentOS/Hyperscale/rpms/ethtool/-/issues/1"
1376            )
1377            .as_deref(),
1378            Some("CentOS/Hyperscale/rpms/ethtool")
1379        );
1380    }
1381
1382    #[test]
1383    fn test_project_path_from_issue_url_no_issues() {
1384        assert_eq!(
1385            project_path_from_issue_url("https://gitlab.com/group/project").as_deref(),
1386            Some("group/project")
1387        );
1388    }
1389
1390    #[test]
1391    fn test_project_path_from_issue_url_no_scheme() {
1392        assert!(project_path_from_issue_url("gitlab.com/group/project").is_none());
1393    }
1394
1395    #[test]
1396    fn test_parse_work_item_id_found() {
1397        let json: serde_json::Value = serde_json::from_str(
1398            r#"{"data":{"project":{"workItems":{"nodes":[{"id":"gid://gitlab/WorkItem/42"}]}}}}"#,
1399        )
1400        .unwrap();
1401        assert_eq!(
1402            parse_work_item_id(&json).as_deref(),
1403            Some("gid://gitlab/WorkItem/42")
1404        );
1405    }
1406
1407    #[test]
1408    fn test_parse_work_item_id_empty() {
1409        let json: serde_json::Value =
1410            serde_json::from_str(r#"{"data":{"project":{"workItems":{"nodes":[]}}}}"#).unwrap();
1411        assert!(parse_work_item_id(&json).is_none());
1412    }
1413
1414    #[test]
1415    fn test_parse_mutation_errors_none() {
1416        let json: serde_json::Value =
1417            serde_json::from_str(r#"{"data":{"workItemUpdate":{"errors":[]}}}"#).unwrap();
1418        assert!(parse_mutation_errors(&json).is_none());
1419    }
1420
1421    #[test]
1422    fn test_parse_mutation_errors_present() {
1423        let json: serde_json::Value = serde_json::from_str(
1424            r#"{"data":{"workItemUpdate":{"errors":["something went wrong"]}}}"#,
1425        )
1426        .unwrap();
1427        let errors = parse_mutation_errors(&json).unwrap();
1428        assert_eq!(errors, vec!["something went wrong"]);
1429    }
1430
1431    #[test]
1432    fn test_parse_status_id_found() {
1433        let json: serde_json::Value = serde_json::from_str(
1434            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"}]}]}]}}}}"#,
1435        ).unwrap();
1436        assert_eq!(
1437            parse_status_id(&json, "In progress").as_deref(),
1438            Some("gid://gitlab/WorkItems::Statuses::Custom::Status/2")
1439        );
1440    }
1441
1442    #[test]
1443    fn test_parse_status_id_not_found() {
1444        let json: serde_json::Value = serde_json::from_str(
1445            r#"{"data":{"project":{"workItemTypes":{"nodes":[{"widgetDefinitions":[{"type":"STATUS","allowedStatuses":[{"id":"gid://id/1","name":"To do"}]}]}]}}}}"#,
1446        ).unwrap();
1447        assert!(parse_status_id(&json, "In progress").is_none());
1448    }
1449
1450    #[test]
1451    fn test_group_client_issues_url() {
1452        let client =
1453            GroupClient::new("https://gitlab.com", "CentOS/Hyperscale/rpms", "fake-token").unwrap();
1454        assert_eq!(
1455            client.issues_url(),
1456            "https://gitlab.com/api/v4/groups/CentOS%2FHyperscale%2Frpms/issues"
1457        );
1458    }
1459
1460    #[test]
1461    fn test_group_client_graphql_url() {
1462        let client =
1463            GroupClient::new("https://gitlab.com", "CentOS/Hyperscale/rpms", "fake-token").unwrap();
1464        assert_eq!(client.graphql_url(), "https://gitlab.com/api/graphql");
1465    }
1466
1467    #[test]
1468    fn test_add_note_success() {
1469        let mut server = mockito::Server::new();
1470        let mock = server
1471            .mock("POST", "/api/v4/projects/g%2Fp/issues/1/notes")
1472            .match_header("private-token", "tok")
1473            .match_body(mockito::Matcher::Json(serde_json::json!({"body": "hello"})))
1474            .with_status(201)
1475            .with_body("{}")
1476            .create();
1477        let client = Client::new(&server.url(), "g/p", "tok").unwrap();
1478        client.add_note(1, "hello").unwrap();
1479        mock.assert();
1480    }
1481
1482    #[test]
1483    fn test_add_note_error() {
1484        let mut server = mockito::Server::new();
1485        let mock = server
1486            .mock("POST", "/api/v4/projects/g%2Fp/issues/1/notes")
1487            .with_status(403)
1488            .with_body("forbidden")
1489            .create();
1490        let client = Client::new(&server.url(), "g/p", "tok").unwrap();
1491        let err = client.add_note(1, "x").unwrap_err();
1492        assert!(err.to_string().contains("403"), "{}", err);
1493        mock.assert();
1494    }
1495
1496    #[test]
1497    fn test_edit_issue_success() {
1498        let mut server = mockito::Server::new();
1499        let mock = server
1500            .mock("PUT", "/api/v4/projects/g%2Fp/issues/5")
1501            .match_header("private-token", "tok")
1502            .with_status(200)
1503            .with_header("content-type", "application/json")
1504            .with_body(r#"{"iid":5,"title":"t","description":null,"state":"closed","web_url":"https://example.com/-/issues/5"}"#)
1505            .create();
1506        let client = Client::new(&server.url(), "g/p", "tok").unwrap();
1507        let updates = IssueUpdate {
1508            state_event: Some("close".into()),
1509            ..Default::default()
1510        };
1511        let issue = client.edit_issue(5, &updates).unwrap();
1512        assert_eq!(issue.state, "closed");
1513        mock.assert();
1514    }
1515
1516    #[test]
1517    fn test_edit_issue_error() {
1518        let mut server = mockito::Server::new();
1519        let mock = server
1520            .mock("PUT", "/api/v4/projects/g%2Fp/issues/5")
1521            .with_status(404)
1522            .with_body("not found")
1523            .create();
1524        let client = Client::new(&server.url(), "g/p", "tok").unwrap();
1525        let updates = IssueUpdate::default();
1526        let err = client.edit_issue(5, &updates).unwrap_err();
1527        assert!(err.to_string().contains("404"), "{}", err);
1528        mock.assert();
1529    }
1530
1531    #[test]
1532    fn test_create_issue_success() {
1533        let mut server = mockito::Server::new();
1534        let mock = server
1535            .mock("POST", "/api/v4/projects/g%2Fp/issues")
1536            .match_header("private-token", "tok")
1537            .with_status(201)
1538            .with_header("content-type", "application/json")
1539            .with_body(r#"{"iid":10,"title":"new issue","description":"desc","state":"opened","web_url":"https://example.com/-/issues/10"}"#)
1540            .create();
1541        let client = Client::new(&server.url(), "g/p", "tok").unwrap();
1542        let issue = client
1543            .create_issue("new issue", Some("desc"), Some("bug"))
1544            .unwrap();
1545        assert_eq!(issue.iid, 10);
1546        assert_eq!(issue.title, "new issue");
1547        mock.assert();
1548    }
1549
1550    #[test]
1551    fn test_list_issues_success() {
1552        let mut server = mockito::Server::new();
1553        let mock = server
1554            .mock("GET", "/api/v4/projects/g%2Fp/issues")
1555            .match_query(mockito::Matcher::AllOf(vec![
1556                mockito::Matcher::UrlEncoded("labels".into(), "relmon".into()),
1557                mockito::Matcher::UrlEncoded("state".into(), "opened".into()),
1558            ]))
1559            .with_status(200)
1560            .with_header("content-type", "application/json")
1561            .with_body(
1562                r#"[{"iid":1,"title":"t","description":null,"state":"opened","web_url":"u"}]"#,
1563            )
1564            .create();
1565        let client = Client::new(&server.url(), "g/p", "tok").unwrap();
1566        let issues = client.list_issues("relmon", Some("opened")).unwrap();
1567        assert_eq!(issues.len(), 1);
1568        assert_eq!(issues[0].iid, 1);
1569        mock.assert();
1570    }
1571
1572    #[test]
1573    fn test_list_issues_error() {
1574        let mut server = mockito::Server::new();
1575        let mock = server
1576            .mock("GET", "/api/v4/projects/g%2Fp/issues")
1577            .match_query(mockito::Matcher::Any)
1578            .with_status(500)
1579            .with_body("internal error")
1580            .create();
1581        let client = Client::new(&server.url(), "g/p", "tok").unwrap();
1582        let err = client.list_issues("relmon", None).unwrap_err();
1583        assert!(err.to_string().contains("500"), "{}", err);
1584        mock.assert();
1585    }
1586
1587    // --- parse_mr_url ---
1588
1589    #[test]
1590    fn parse_mr_url_standard() {
1591        let (base, project, iid) =
1592            parse_mr_url("https://gitlab.com/redhat/centos-stream/rpms/xz/-/merge_requests/42")
1593                .unwrap();
1594        assert_eq!(base, "https://gitlab.com");
1595        assert_eq!(project, "redhat/centos-stream/rpms/xz");
1596        assert_eq!(iid, 42);
1597    }
1598
1599    #[test]
1600    fn parse_mr_url_strips_trailing_slash() {
1601        let (_, _, iid) =
1602            parse_mr_url("https://gitlab.com/redhat/centos-stream/rpms/xz/-/merge_requests/42/")
1603                .unwrap();
1604        assert_eq!(iid, 42);
1605    }
1606
1607    #[test]
1608    fn parse_mr_url_strips_query() {
1609        let (_, _, iid) =
1610            parse_mr_url("https://gitlab.com/a/b/-/merge_requests/7?commit_id=abc").unwrap();
1611        assert_eq!(iid, 7);
1612    }
1613
1614    #[test]
1615    fn parse_mr_url_strips_fragment() {
1616        let (_, _, iid) =
1617            parse_mr_url("https://gitlab.com/a/b/-/merge_requests/7#note_123").unwrap();
1618        assert_eq!(iid, 7);
1619    }
1620
1621    #[test]
1622    fn parse_mr_url_rejects_issue_url() {
1623        assert!(parse_mr_url("https://gitlab.com/a/b/-/issues/1").is_err());
1624    }
1625
1626    #[test]
1627    fn parse_mr_url_rejects_non_numeric_iid() {
1628        assert!(parse_mr_url("https://gitlab.com/a/b/-/merge_requests/abc").is_err());
1629    }
1630
1631    #[test]
1632    fn parse_mr_url_rejects_no_scheme() {
1633        assert!(parse_mr_url("gitlab.com/a/b/-/merge_requests/1").is_err());
1634    }
1635
1636    #[test]
1637    fn parse_issue_url_handles_legacy_form() {
1638        let (base, project, iid) =
1639            parse_issue_url("https://gitlab.com/group/project/-/issues/42").unwrap();
1640        assert_eq!(base, "https://gitlab.com");
1641        assert_eq!(project, "group/project");
1642        assert_eq!(iid, 42);
1643    }
1644
1645    #[test]
1646    fn parse_issue_url_handles_work_items_form() {
1647        let (base, project, iid) =
1648            parse_issue_url("https://gitlab.com/CentOS/proposed_updates/rpms/xz/-/work_items/1")
1649                .unwrap();
1650        assert_eq!(base, "https://gitlab.com");
1651        assert_eq!(project, "CentOS/proposed_updates/rpms/xz");
1652        assert_eq!(iid, 1);
1653    }
1654
1655    #[test]
1656    fn parse_issue_url_strips_query_and_fragment() {
1657        let (_, _, iid) =
1658            parse_issue_url("https://gitlab.com/a/b/-/work_items/7?note=123#xyz").unwrap();
1659        assert_eq!(iid, 7);
1660    }
1661
1662    #[test]
1663    fn parse_issue_url_rejects_mr_url() {
1664        assert!(parse_issue_url("https://gitlab.com/a/b/-/merge_requests/1").is_err());
1665    }
1666
1667    #[test]
1668    fn parse_issue_url_rejects_non_numeric_iid() {
1669        assert!(parse_issue_url("https://gitlab.com/a/b/-/issues/xyz").is_err());
1670    }
1671
1672    #[test]
1673    fn user_by_username_returns_first_match() {
1674        let mut server = mockito::Server::new();
1675        let mock = server
1676            .mock("GET", "/api/v4/users?username=alice")
1677            .match_header("private-token", "tok")
1678            .with_status(200)
1679            .with_body(r#"[{"id": 42, "username": "alice"}]"#)
1680            .create();
1681        let user = user_by_username(&server.url(), "tok", "alice").unwrap();
1682        assert_eq!(user.as_ref().map(|u| u.id), Some(42));
1683        assert_eq!(user.as_ref().map(|u| u.username.as_str()), Some("alice"));
1684        mock.assert();
1685    }
1686
1687    #[test]
1688    fn user_by_username_empty_list_is_none() {
1689        let mut server = mockito::Server::new();
1690        let mock = server
1691            .mock("GET", "/api/v4/users?username=ghost")
1692            .with_status(200)
1693            .with_body("[]")
1694            .create();
1695        let user = user_by_username(&server.url(), "tok", "ghost").unwrap();
1696        assert!(user.is_none());
1697        mock.assert();
1698    }
1699
1700    #[test]
1701    fn user_events_single_page() {
1702        let mut server = mockito::Server::new();
1703        let mock = server
1704            .mock("GET", mockito::Matcher::Any)
1705            .match_query(mockito::Matcher::AllOf(vec![
1706                mockito::Matcher::UrlEncoded("page".into(), "1".into()),
1707                mockito::Matcher::UrlEncoded("per_page".into(), "100".into()),
1708                mockito::Matcher::UrlEncoded("after".into(), "2026-01-01".into()),
1709                mockito::Matcher::UrlEncoded("before".into(), "2026-03-31".into()),
1710                mockito::Matcher::UrlEncoded("action".into(), "created".into()),
1711            ]))
1712            .with_status(200)
1713            .with_body(
1714                r#"[{"id": 1, "project_id": 10, "action_name": "opened",
1715                    "target_type": "MergeRequest", "target_iid": 123,
1716                    "target_title": "Fix X", "created_at": "2026-02-15T10:00:00Z"}]"#,
1717            )
1718            .create();
1719        let events = user_events(
1720            &server.url(),
1721            "tok",
1722            42,
1723            Some("created"),
1724            chrono::NaiveDate::from_ymd_opt(2026, 1, 1).unwrap(),
1725            chrono::NaiveDate::from_ymd_opt(2026, 3, 31).unwrap(),
1726        )
1727        .unwrap();
1728        assert_eq!(events.len(), 1);
1729        assert_eq!(events[0].target_iid, Some(123));
1730        assert_eq!(events[0].action_name, "opened");
1731        mock.assert();
1732    }
1733
1734    #[test]
1735    fn event_deserializes_push_data() {
1736        let json = r#"{
1737            "id": 5,
1738            "project_id": 10,
1739            "action_name": "pushed to",
1740            "created_at": "2026-02-15T10:00:00Z",
1741            "push_data": {"commit_count": 3, "ref": "main", "action": "pushed",
1742                          "ref_type": "branch", "commit_title": "Fix typo"}
1743        }"#;
1744        let e: Event = serde_json::from_str(json).unwrap();
1745        let push = e.push_data.unwrap();
1746        assert_eq!(push.commit_count, 3);
1747        assert_eq!(push.ref_name.as_deref(), Some("main"));
1748    }
1749
1750    #[test]
1751    fn count_authored_commits_paginates_and_sums() {
1752        let mut server = mockito::Server::new();
1753        let mock_p1 = server
1754            .mock("GET", "/api/v4/projects/10/repository/commits")
1755            .match_query(mockito::Matcher::AllOf(vec![
1756                mockito::Matcher::UrlEncoded("page".into(), "1".into()),
1757                mockito::Matcher::UrlEncoded("per_page".into(), "100".into()),
1758                mockito::Matcher::UrlEncoded("author".into(), "michel-slm".into()),
1759            ]))
1760            .with_status(200)
1761            // 100 entries → paginator fetches another page.
1762            .with_body(format!("[{}]", vec!["{}"; 100].join(",")))
1763            .create();
1764        let mock_p2 = server
1765            .mock("GET", "/api/v4/projects/10/repository/commits")
1766            .match_query(mockito::Matcher::UrlEncoded("page".into(), "2".into()))
1767            .with_status(200)
1768            .with_body("[{},{},{}]")
1769            .create();
1770        let n = count_authored_commits(
1771            &server.url(),
1772            "tok",
1773            10,
1774            "michel-slm",
1775            chrono::NaiveDate::from_ymd_opt(2026, 1, 1).unwrap(),
1776            chrono::NaiveDate::from_ymd_opt(2026, 3, 31).unwrap(),
1777        )
1778        .unwrap();
1779        assert_eq!(n, 103);
1780        mock_p1.assert();
1781        mock_p2.assert();
1782    }
1783
1784    #[test]
1785    fn project_summary_returns_path() {
1786        let mut server = mockito::Server::new();
1787        let mock = server
1788            .mock("GET", "/api/v4/projects/10")
1789            .with_status(200)
1790            .with_body(
1791                r#"{"id": 10, "path_with_namespace": "CentOS/Hyperscale/rpms/perf",
1792                    "web_url": "https://gitlab.com/CentOS/Hyperscale/rpms/perf"}"#,
1793            )
1794            .create();
1795        let p = project_summary(&server.url(), "tok", 10).unwrap();
1796        assert_eq!(p.path_with_namespace, "CentOS/Hyperscale/rpms/perf");
1797        mock.assert();
1798    }
1799}