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