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