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