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