Skip to main content

shiplog_ingest_gitlab/
lib.rs

1//! GitLab API ingestor with adaptive date slicing and cache support.
2//!
3//! Collects MR/review events, tracks coverage slices, and marks partial
4//! completeness when search caps or incomplete API responses are detected.
5
6use anyhow::{Context, Result, anyhow};
7use chrono::{DateTime, NaiveDate, Utc};
8use reqwest::blocking::Client;
9use serde::Deserialize;
10use serde::de::DeserializeOwned;
11use shiplog_cache::ApiCache;
12use shiplog_cache::CacheKey;
13use shiplog_ids::{EventId, RunId};
14use shiplog_ports::{IngestOutput, Ingestor};
15use shiplog_schema::coverage::{Completeness, CoverageManifest, CoverageSlice, TimeWindow};
16use shiplog_schema::event::{
17    Actor, EventEnvelope, EventKind, EventPayload, Link, PullRequestEvent, PullRequestState,
18    RepoRef, RepoVisibility, ReviewEvent, SourceRef, SourceSystem,
19};
20use std::path::PathBuf;
21use std::thread::sleep;
22use std::time::Duration;
23
24/// GitLab MR state filter
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum MrState {
27    Opened,
28    Merged,
29    Closed,
30    All,
31}
32
33impl MrState {
34    pub fn as_str(&self) -> &str {
35        match self {
36            Self::Opened => "opened",
37            Self::Merged => "merged",
38            Self::Closed => "closed",
39            Self::All => "all",
40        }
41    }
42}
43
44impl std::str::FromStr for MrState {
45    type Err = anyhow::Error;
46
47    fn from_str(s: &str) -> Result<Self> {
48        match s.to_lowercase().as_str() {
49            "opened" => Ok(Self::Opened),
50            "merged" => Ok(Self::Merged),
51            "closed" => Ok(Self::Closed),
52            "all" => Ok(Self::All),
53            _ => Err(anyhow!("Invalid MR state: {}", s)),
54        }
55    }
56}
57
58#[derive(Debug)]
59pub struct GitlabIngestor {
60    pub user: String,
61    pub since: NaiveDate,
62    pub until: NaiveDate,
63    pub state: MrState,
64    pub include_reviews: bool,
65    pub fetch_details: bool,
66    pub throttle_ms: u64,
67    pub token: Option<String>,
68    /// GitLab instance hostname (e.g., "gitlab.com" or "gitlab.company.com")
69    pub instance: String,
70    /// Optional cache for API responses
71    pub cache: Option<ApiCache>,
72}
73
74impl GitlabIngestor {
75    pub fn new(user: String, since: NaiveDate, until: NaiveDate) -> Self {
76        Self {
77            user,
78            since,
79            until,
80            state: MrState::Merged,
81            include_reviews: false,
82            fetch_details: true,
83            throttle_ms: 0,
84            token: None,
85            instance: "gitlab.com".to_string(),
86            cache: None,
87        }
88    }
89
90    /// Set the GitLab personal access token.
91    pub fn with_token(mut self, token: String) -> Result<Self> {
92        if token.is_empty() {
93            return Err(anyhow!("GitLab token cannot be empty"));
94        }
95        self.token = Some(token);
96        Ok(self)
97    }
98
99    /// Set the GitLab instance hostname.
100    pub fn with_instance(mut self, instance: String) -> Result<Self> {
101        // Validate the instance URL format
102        if instance.is_empty() {
103            return Err(anyhow!("GitLab instance cannot be empty"));
104        }
105
106        // Remove protocol if present and validate hostname
107        let hostname = if instance.contains("://") {
108            url::Url::parse(&instance)
109                .ok()
110                .and_then(|u| u.host_str().map(|s| s.to_string()))
111                .ok_or_else(|| anyhow!("Invalid GitLab instance URL: {}", instance))?
112        } else {
113            instance.clone()
114        };
115
116        self.instance = hostname;
117        Ok(self)
118    }
119
120    /// Set the MR state filter.
121    pub fn with_state(mut self, state: MrState) -> Self {
122        self.state = state;
123        self
124    }
125
126    /// Enable review collection.
127    pub fn with_include_reviews(mut self, include: bool) -> Self {
128        self.include_reviews = include;
129        self
130    }
131
132    /// Enable caching with the given cache directory.
133    pub fn with_cache(mut self, cache_dir: impl Into<PathBuf>) -> Result<Self> {
134        let cache_path = cache_dir.into().join("gitlab-api-cache.db");
135        if let Some(parent) = cache_path.parent() {
136            std::fs::create_dir_all(parent)
137                .with_context(|| format!("create GitLab cache directory {parent:?}"))?;
138        }
139        let cache = ApiCache::open(cache_path)?;
140        self.cache = Some(cache);
141        Ok(self)
142    }
143
144    /// Enable in-memory caching (useful for testing).
145    pub fn with_in_memory_cache(mut self) -> Result<Self> {
146        let cache = ApiCache::open_in_memory()?;
147        self.cache = Some(cache);
148        Ok(self)
149    }
150
151    /// Set throttle delay between API requests (in milliseconds).
152    pub fn with_throttle(mut self, ms: u64) -> Self {
153        self.throttle_ms = ms;
154        self
155    }
156
157    fn html_base_url(&self) -> String {
158        format!("https://{}", self.instance)
159    }
160
161    fn api_base_url(&self) -> String {
162        format!("https://{}/api/v4", self.instance)
163    }
164
165    #[mutants::skip]
166    fn client(&self) -> Result<Client> {
167        Client::builder()
168            .user_agent(concat!("shiplog/", env!("CARGO_PKG_VERSION")))
169            .build()
170            .context("build reqwest client")
171    }
172
173    #[mutants::skip]
174    fn api_url(&self, path: &str) -> String {
175        let base = self.api_base_url();
176        format!("{}{}", base.trim_end_matches('/'), path)
177    }
178
179    #[mutants::skip]
180    fn throttle(&self) {
181        if self.throttle_ms > 0 {
182            sleep(Duration::from_millis(self.throttle_ms));
183        }
184    }
185
186    #[mutants::skip]
187    fn get_json<T: DeserializeOwned>(
188        &self,
189        client: &Client,
190        url: &str,
191        params: &[(&str, String)],
192    ) -> Result<T> {
193        let request_url = build_url_with_params(url, params)?;
194        let request_url_for_err = request_url.as_str().to_string();
195
196        let mut req = client.get(request_url).header("Accept", "application/json");
197
198        // GitLab uses PRIVATE-TOKEN header for authentication
199        if let Some(t) = &self.token {
200            req = req.header("PRIVATE-TOKEN", t);
201        }
202
203        let resp = req
204            .send()
205            .with_context(|| format!("GET {request_url_for_err}"))?;
206        self.throttle();
207
208        let status = resp.status();
209        if !status.is_success() {
210            let body = resp.text().unwrap_or_default();
211
212            // Handle specific GitLab error cases
213            if status.as_u16() == 401 {
214                return Err(anyhow!(
215                    "GitLab authentication failed: invalid or expired token"
216                ));
217            } else if status.as_u16() == 403 {
218                if body.to_lowercase().contains("rate limit") {
219                    return Err(anyhow!("GitLab API rate limit exceeded"));
220                }
221                return Err(anyhow!("GitLab API access forbidden: {}", body));
222            } else if status.as_u16() == 404 {
223                return Err(anyhow!("GitLab resource not found: {}", body));
224            }
225
226            return Err(anyhow!("GitLab API error {status}: {body}"));
227        }
228
229        resp.json::<T>()
230            .with_context(|| format!("parse json from {request_url_for_err}"))
231    }
232
233    /// Get user ID from username (required for GitLab API queries)
234    #[mutants::skip]
235    fn get_user_id(&self, client: &Client) -> Result<u64> {
236        let url = self.api_url(&format!("/users?username={}", self.user));
237        let users: Vec<GitlabUser> = self.get_json(client, &url, &[])?;
238
239        users
240            .into_iter()
241            .find(|u| u.username == self.user)
242            .map(|u| u.id)
243            .ok_or_else(|| anyhow!("GitLab user '{}' not found", self.user))
244    }
245
246    /// Get projects accessible to the user
247    #[mutants::skip]
248    fn get_user_projects(&self, client: &Client, user_id: u64) -> Result<Vec<GitlabProject>> {
249        let url = self.api_url(&format!("/users/{}/projects", user_id));
250        let mut projects = Vec::new();
251        let per_page = 100;
252
253        for page in 1..=10 {
254            let page_projects: Vec<GitlabProject> = self.get_json(
255                client,
256                &url,
257                &[
258                    ("per_page", per_page.to_string()),
259                    ("page", page.to_string()),
260                    ("order_by", "updated_at".to_string()),
261                    ("sort", "desc".to_string()),
262                ],
263            )?;
264
265            let n = page_projects.len();
266            projects.extend(page_projects);
267
268            if n < per_page {
269                break;
270            }
271        }
272
273        Ok(projects)
274    }
275
276    /// Collect MRs from projects
277    #[mutants::skip]
278    fn collect_mrs_from_projects(
279        &self,
280        client: &Client,
281        projects: Vec<GitlabProject>,
282    ) -> Result<(Vec<GitlabMergeRequest>, Vec<CoverageSlice>, bool)> {
283        let mut all_mrs = Vec::new();
284        let mut slices = Vec::new();
285        let partial = false;
286
287        for project in projects {
288            let url = self.api_url(&format!("/projects/{}/merge_requests", project.id));
289
290            let mut params = vec![
291                ("author_username", self.user.clone()),
292                ("per_page", "100".to_string()),
293                ("order_by", "created_at".to_string()),
294                ("sort", "desc".to_string()),
295            ];
296
297            // Add state filter
298            if self.state != MrState::All {
299                params.push(("state", self.state.as_str().to_string()));
300            }
301
302            // Add date filters
303            let start = self.since.format("%Y-%m-%d").to_string();
304            let end = self.until.format("%Y-%m-%d").to_string();
305            params.push(("created_after", start));
306            params.push(("created_before", end));
307
308            let mut page_mrs: Vec<GitlabMergeRequest> = match self.get_json(client, &url, &params) {
309                Ok(mrs) => mrs,
310                Err(e) => {
311                    // Skip projects we can't access (e.g., private projects)
312                    if e.to_string().contains("404") || e.to_string().contains("403") {
313                        continue;
314                    }
315                    return Err(e);
316                }
317            };
318
319            let mr_count = page_mrs.len() as u64;
320            for mr in &mut page_mrs {
321                mr.project_path = Some(project.path_with_namespace.clone());
322                mr.project_public = Some(project.public);
323            }
324            slices.push(CoverageSlice {
325                window: TimeWindow {
326                    since: self.since,
327                    until: self.until,
328                },
329                query: format!(
330                    "project:{} MRs by {}",
331                    project.path_with_namespace, self.user
332                ),
333                total_count: mr_count,
334                fetched: mr_count,
335                incomplete_results: Some(false),
336                notes: vec![format!("project:{}", project.path_with_namespace)],
337            });
338
339            all_mrs.extend(page_mrs);
340        }
341
342        Ok((all_mrs, slices, partial))
343    }
344
345    /// Collect notes (reviews) for an MR
346    #[mutants::skip]
347    fn collect_mr_notes(
348        &self,
349        client: &Client,
350        project_id: u64,
351        mr_iid: u64,
352    ) -> Result<Vec<GitlabNote>> {
353        let url = self.api_url(&format!(
354            "/projects/{}/merge_requests/{}/notes",
355            project_id, mr_iid
356        ));
357
358        let mut notes = Vec::new();
359        let per_page = 100;
360
361        for page in 1..=10 {
362            let cache_key = CacheKey::mr_notes(project_id, mr_iid, page);
363
364            let page_notes: Vec<GitlabNote> = if let Some(ref cache) = self.cache {
365                if let Some(cached) = cache.get::<Vec<GitlabNote>>(&cache_key)? {
366                    cached
367                } else {
368                    let notes: Vec<GitlabNote> = self.get_json(
369                        client,
370                        &url,
371                        &[
372                            ("per_page", per_page.to_string()),
373                            ("page", page.to_string()),
374                        ],
375                    )?;
376                    cache.set(&cache_key, &notes)?;
377                    notes
378                }
379            } else {
380                self.get_json(
381                    client,
382                    &url,
383                    &[
384                        ("per_page", per_page.to_string()),
385                        ("page", page.to_string()),
386                    ],
387                )?
388            };
389
390            let n = page_notes.len();
391            notes.extend(page_notes);
392
393            if n < per_page {
394                break;
395            }
396        }
397
398        Ok(notes)
399    }
400
401    /// Convert GitLab MRs to shiplog events
402    #[mutants::skip]
403    fn mrs_to_events(&self, mrs: Vec<GitlabMergeRequest>) -> Result<Vec<EventEnvelope>> {
404        let mut events = Vec::new();
405        let html_base = self.html_base_url();
406
407        for mr in mrs {
408            let state = match mr.state.as_str() {
409                "opened" => PullRequestState::Open,
410                "merged" => PullRequestState::Merged,
411                "closed" => PullRequestState::Closed,
412                _ => PullRequestState::Unknown,
413            };
414            let project_path = mr.project_path()?;
415            let project_public = mr.project_public();
416
417            let mr_url = mr.web_url.clone().unwrap_or_else(|| {
418                format!("{}/{}/-/merge_requests/{}", html_base, project_path, mr.iid)
419            });
420
421            let event = EventEnvelope {
422                id: EventId::from_parts(["gitlab", "mr", &mr.id.to_string()]),
423                kind: EventKind::PullRequest,
424                occurred_at: mr.created_at,
425                actor: Actor {
426                    login: mr.author.username,
427                    id: Some(mr.author.id),
428                },
429                repo: RepoRef {
430                    full_name: project_path.clone(),
431                    html_url: Some(format!("{}/{}", html_base, project_path)),
432                    visibility: if project_public {
433                        RepoVisibility::Public
434                    } else {
435                        RepoVisibility::Private
436                    },
437                },
438                payload: EventPayload::PullRequest(PullRequestEvent {
439                    number: mr.iid,
440                    title: mr.title,
441                    state,
442                    created_at: mr.created_at,
443                    merged_at: mr.merged_at,
444                    additions: mr.additions,
445                    deletions: mr.deletions,
446                    changed_files: mr.changed_files,
447                    touched_paths_hint: vec![],
448                    window: None,
449                }),
450                tags: mr.labels,
451                links: vec![Link {
452                    label: "GitLab MR".to_string(),
453                    url: mr_url.clone(),
454                }],
455                source: SourceRef {
456                    system: SourceSystem::Other("gitlab".to_string()),
457                    url: Some(mr_url.clone()),
458                    opaque_id: Some(mr.id.to_string()),
459                },
460            };
461
462            events.push(event);
463        }
464
465        Ok(events)
466    }
467
468    /// Convert GitLab notes to shiplog review events
469    #[mutants::skip]
470    fn notes_to_review_events(
471        &self,
472        notes: Vec<GitlabNote>,
473        mr: &GitlabMergeRequest,
474    ) -> Result<Vec<EventEnvelope>> {
475        let mut events = Vec::new();
476        let html_base = self.html_base_url();
477
478        for note in notes {
479            // Only include notes that are actual reviews (not system notes or comments)
480            if note.system || note.author.username == self.user {
481                continue;
482            }
483
484            let project_path = mr.project_path()?;
485            let project_public = mr.project_public();
486            let mr_url = match &mr.web_url {
487                Some(url) => format!("{}#note_{}", url, note.id),
488                None => format!(
489                    "{}/{}/-/merge_requests/{}#note_{}",
490                    html_base, project_path, mr.iid, note.id
491                ),
492            };
493
494            let event = EventEnvelope {
495                id: EventId::from_parts(["gitlab", "review", &note.id.to_string()]),
496                kind: EventKind::Review,
497                occurred_at: note.created_at,
498                actor: Actor {
499                    login: note.author.username,
500                    id: Some(note.author.id),
501                },
502                repo: RepoRef {
503                    full_name: project_path.clone(),
504                    html_url: Some(format!("{}/{}", html_base, project_path)),
505                    visibility: if project_public {
506                        RepoVisibility::Public
507                    } else {
508                        RepoVisibility::Private
509                    },
510                },
511                payload: EventPayload::Review(ReviewEvent {
512                    pull_number: mr.iid,
513                    pull_title: mr.title.clone(),
514                    submitted_at: note.created_at,
515                    state: "approved".to_string(),
516                    window: None,
517                }),
518                tags: vec![],
519                links: vec![Link {
520                    label: "GitLab Review".to_string(),
521                    url: mr_url.clone(),
522                }],
523                source: SourceRef {
524                    system: SourceSystem::Other("gitlab".to_string()),
525                    url: Some(mr_url.clone()),
526                    opaque_id: Some(note.id.to_string()),
527                },
528            };
529
530            events.push(event);
531        }
532
533        Ok(events)
534    }
535}
536
537impl Ingestor for GitlabIngestor {
538    #[mutants::skip]
539    fn ingest(&self) -> Result<IngestOutput> {
540        if self.since >= self.until {
541            return Err(anyhow!("since must be < until"));
542        }
543
544        let _token = self.token.as_ref().ok_or_else(|| {
545            anyhow!("GitLab token is required. Set it using with_token() or GITLAB_TOKEN environment variable")
546        })?;
547
548        let client = self.client()?;
549        let run_id = RunId::now("shiplog");
550        let mut slices: Vec<CoverageSlice> = Vec::new();
551        let mut warnings: Vec<String> = Vec::new();
552        let mut completeness = Completeness::Complete;
553
554        let mut events: Vec<EventEnvelope> = Vec::new();
555
556        // Get user ID
557        let user_id = self.get_user_id(&client)?;
558
559        // Get user's projects
560        let projects = self.get_user_projects(&client, user_id)?;
561
562        if projects.is_empty() {
563            warnings.push("No projects found for user. This may be due to insufficient permissions or no activity.".to_string());
564        }
565
566        // Collect MRs from projects
567        let (mrs, mr_slices, mr_partial) = self.collect_mrs_from_projects(&client, projects)?;
568        slices.extend(mr_slices);
569        if mr_partial {
570            completeness = Completeness::Partial;
571        }
572
573        // Convert MRs to events
574        events.extend(self.mrs_to_events(mrs)?);
575
576        // Collect reviews if enabled
577        if self.include_reviews {
578            warnings.push(
579                "Reviews are collected via MR notes; treat as best-effort coverage.".to_string(),
580            );
581
582            let client = self.client()?;
583            let user_id = self.get_user_id(&client)?;
584            let projects = self.get_user_projects(&client, user_id)?;
585
586            let (mrs, _, _) = self.collect_mrs_from_projects(&client, projects)?;
587
588            for mr in mrs {
589                let notes = self.collect_mr_notes(&client, mr.project_id, mr.iid)?;
590                events.extend(self.notes_to_review_events(notes, &mr)?);
591            }
592        }
593
594        // Sort for stable output
595        events.sort_by_key(|e| e.occurred_at);
596
597        let cov = CoverageManifest {
598            run_id,
599            generated_at: Utc::now(),
600            user: self.user.clone(),
601            window: TimeWindow {
602                since: self.since,
603                until: self.until,
604            },
605            mode: self.state.as_str().to_string(),
606            sources: vec!["gitlab".to_string()],
607            slices,
608            warnings,
609            completeness,
610        };
611
612        Ok(IngestOutput {
613            events,
614            coverage: cov,
615            freshness: Vec::new(),
616        })
617    }
618}
619
620// GitLab API types
621
622#[derive(Debug, Deserialize)]
623struct GitlabUser {
624    id: u64,
625    username: String,
626}
627
628#[derive(Debug, Deserialize)]
629#[allow(dead_code)]
630struct GitlabProject {
631    id: u64,
632    path_with_namespace: String,
633    public: bool,
634}
635
636#[derive(Debug, Deserialize)]
637#[allow(dead_code)]
638struct GitlabMergeRequest {
639    id: u64,
640    iid: u64,
641    project_id: u64,
642    title: String,
643    state: String,
644    created_at: DateTime<Utc>,
645    merged_at: Option<DateTime<Utc>>,
646    closed_at: Option<DateTime<Utc>>,
647    additions: Option<u64>,
648    deletions: Option<u64>,
649    changed_files: Option<u64>,
650    labels: Vec<String>,
651    author: GitlabAuthor,
652    web_url: Option<String>,
653    #[serde(default)]
654    project: Option<GitlabProjectInfo>,
655    #[serde(skip)]
656    project_path: Option<String>,
657    #[serde(skip)]
658    project_public: Option<bool>,
659}
660
661impl GitlabMergeRequest {
662    fn project_path(&self) -> Result<String> {
663        if let Some(path) = &self.project_path {
664            return Ok(path.clone());
665        }
666
667        if let Some(project) = &self.project {
668            return Ok(project.path_with_namespace.clone());
669        }
670
671        if let Some(web_url) = &self.web_url
672            && let Some(path) = project_path_from_mr_web_url(web_url)
673        {
674            return Ok(path);
675        }
676
677        Err(anyhow!(
678            "GitLab MR {} is missing project path context",
679            self.id
680        ))
681    }
682
683    fn project_public(&self) -> bool {
684        self.project_public
685            .or_else(|| self.project.as_ref().map(|project| project.public))
686            .unwrap_or(false)
687    }
688}
689
690#[derive(Debug, Deserialize, serde::Serialize)]
691struct GitlabAuthor {
692    id: u64,
693    username: String,
694}
695
696#[derive(Debug, Deserialize)]
697#[allow(dead_code)]
698struct GitlabProjectInfo {
699    id: u64,
700    path_with_namespace: String,
701    public: bool,
702}
703
704#[derive(Debug, Deserialize, serde::Serialize)]
705struct GitlabNote {
706    id: u64,
707    system: bool,
708    created_at: DateTime<Utc>,
709    author: GitlabAuthor,
710}
711
712fn build_url_with_params(base: &str, params: &[(&str, String)]) -> Result<url::Url> {
713    let mut url = url::Url::parse(base).with_context(|| format!("parse url {base}"))?;
714    if !params.is_empty() {
715        let mut query = url.query_pairs_mut();
716        for (k, v) in params {
717            query.append_pair(k, v);
718        }
719    }
720    Ok(url)
721}
722
723fn project_path_from_mr_web_url(web_url: &str) -> Option<String> {
724    let url = url::Url::parse(web_url).ok()?;
725    let segments: Vec<_> = url.path_segments()?.collect();
726    let marker = segments
727        .windows(2)
728        .position(|pair| pair[0] == "-" && pair[1] == "merge_requests")
729        .or_else(|| {
730            segments
731                .iter()
732                .position(|segment| *segment == "merge_requests")
733        })?;
734
735    if marker == 0 {
736        return None;
737    }
738
739    Some(segments[..marker].join("/"))
740}
741
742#[cfg(test)]
743mod tests {
744    use super::*;
745    use proptest::prelude::*;
746
747    // ── Helpers ─────────────────────────────────────────────────────────
748
749    fn default_ingestor() -> GitlabIngestor {
750        GitlabIngestor::new(
751            "alice".to_string(),
752            NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
753            NaiveDate::from_ymd_opt(2025, 2, 1).unwrap(),
754        )
755    }
756
757    fn sample_mr_json() -> serde_json::Value {
758        serde_json::json!({
759            "id": 101,
760            "iid": 42,
761            "project_id": 7,
762            "title": "Add feature X",
763            "state": "merged",
764            "created_at": "2025-01-10T12:00:00Z",
765            "merged_at": "2025-01-11T08:30:00Z",
766            "closed_at": null,
767            "additions": 120,
768            "deletions": 30,
769            "changed_files": 5,
770            "labels": ["backend", "feature"],
771            "author": { "id": 1, "username": "alice" },
772            "project": { "id": 7, "path_with_namespace": "org/repo", "public": true }
773        })
774    }
775
776    fn sample_note_json() -> serde_json::Value {
777        serde_json::json!({
778            "id": 501,
779            "system": false,
780            "created_at": "2025-01-10T14:00:00Z",
781            "author": { "id": 2, "username": "bob" }
782        })
783    }
784
785    // ── Existing tests (preserved) ──────────────────────────────────────
786
787    #[test]
788    fn with_cache_creates_missing_directory() {
789        let temp = tempfile::tempdir().unwrap();
790        let cache_dir = temp.path().join("nested").join("cache");
791
792        let ing = GitlabIngestor::new(
793            "alice".to_string(),
794            NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
795            NaiveDate::from_ymd_opt(2025, 2, 1).unwrap(),
796        )
797        .with_cache(&cache_dir)
798        .unwrap();
799
800        assert!(ing.cache.is_some());
801        assert!(cache_dir.join("gitlab-api-cache.db").exists());
802    }
803
804    #[test]
805    fn with_in_memory_cache_works() {
806        let ing = GitlabIngestor::new(
807            "alice".to_string(),
808            NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
809            NaiveDate::from_ymd_opt(2025, 2, 1).unwrap(),
810        )
811        .with_in_memory_cache()
812        .unwrap();
813
814        assert!(ing.cache.is_some());
815    }
816
817    #[test]
818    fn collect_mr_notes_replays_cached_payload_without_network() {
819        let ing = default_ingestor()
820            .with_instance("127.0.0.1:9".to_string())
821            .unwrap()
822            .with_in_memory_cache()
823            .unwrap();
824        let notes: Vec<GitlabNote> = serde_json::from_value(serde_json::json!([
825            {
826                "id": 9001,
827                "type": null,
828                "body": "LGTM, rollback path is clear.",
829                "attachment": null,
830                "author": {
831                    "id": 101,
832                    "name": "Bob Reviewer",
833                    "username": "bob",
834                    "state": "active",
835                    "avatar_url": null,
836                    "web_url": "https://gitlab.example.com/bob"
837                },
838                "created_at": "2025-01-12T16:30:00Z",
839                "updated_at": "2025-01-12T16:30:00Z",
840                "system": false,
841                "noteable_id": 424242,
842                "noteable_type": "MergeRequest",
843                "project_id": 3001,
844                "resolvable": false,
845                "confidential": false,
846                "internal": false,
847                "noteable_iid": 42
848            }
849        ]))
850        .unwrap();
851        ing.cache
852            .as_ref()
853            .unwrap()
854            .set(&CacheKey::mr_notes(3001, 42, 1), &notes)
855            .unwrap();
856
857        let client = Client::new();
858        let replayed = ing.collect_mr_notes(&client, 3001, 42).unwrap();
859
860        assert_eq!(replayed.len(), 1);
861        assert_eq!(replayed[0].id, 9001);
862        assert_eq!(replayed[0].author.username, "bob");
863        assert!(!replayed[0].system);
864    }
865
866    #[test]
867    fn with_token_validates_non_empty() {
868        let result = GitlabIngestor::new(
869            "alice".to_string(),
870            NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
871            NaiveDate::from_ymd_opt(2025, 2, 1).unwrap(),
872        )
873        .with_token("".to_string());
874
875        assert!(result.is_err());
876        assert!(result.unwrap_err().to_string().contains("cannot be empty"));
877    }
878
879    #[test]
880    fn with_instance_validates_format() {
881        let result = GitlabIngestor::new(
882            "alice".to_string(),
883            NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
884            NaiveDate::from_ymd_opt(2025, 2, 1).unwrap(),
885        )
886        .with_instance("".to_string());
887
888        assert!(result.is_err());
889        assert!(result.unwrap_err().to_string().contains("cannot be empty"));
890
891        let result = GitlabIngestor::new(
892            "alice".to_string(),
893            NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
894            NaiveDate::from_ymd_opt(2025, 2, 1).unwrap(),
895        )
896        .with_instance("http://".to_string());
897
898        assert!(result.is_err());
899        assert!(result.unwrap_err().to_string().contains("Invalid"));
900    }
901
902    #[test]
903    fn with_instance_strips_protocol() {
904        let ing = GitlabIngestor::new(
905            "alice".to_string(),
906            NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
907            NaiveDate::from_ymd_opt(2025, 2, 1).unwrap(),
908        )
909        .with_instance("https://gitlab.company.com".to_string())
910        .unwrap();
911
912        assert_eq!(ing.instance, "gitlab.company.com");
913    }
914
915    #[test]
916    fn mr_state_from_str() {
917        assert_eq!("opened".parse::<MrState>().unwrap(), MrState::Opened);
918        assert_eq!("merged".parse::<MrState>().unwrap(), MrState::Merged);
919        assert_eq!("closed".parse::<MrState>().unwrap(), MrState::Closed);
920        assert_eq!("all".parse::<MrState>().unwrap(), MrState::All);
921        assert!("invalid".parse::<MrState>().is_err());
922    }
923
924    #[test]
925    fn mr_state_as_str() {
926        assert_eq!(MrState::Opened.as_str(), "opened");
927        assert_eq!(MrState::Merged.as_str(), "merged");
928        assert_eq!(MrState::Closed.as_str(), "closed");
929        assert_eq!(MrState::All.as_str(), "all");
930    }
931
932    #[test]
933    fn html_base_url_constructs_correctly() {
934        let mut ing = GitlabIngestor::new(
935            "alice".to_string(),
936            NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
937            NaiveDate::from_ymd_opt(2025, 2, 1).unwrap(),
938        );
939        ing.instance = "gitlab.com".to_string();
940        assert_eq!(ing.html_base_url(), "https://gitlab.com");
941
942        ing.instance = "gitlab.company.com".to_string();
943        assert_eq!(ing.html_base_url(), "https://gitlab.company.com");
944    }
945
946    #[test]
947    fn api_base_url_constructs_correctly() {
948        let mut ing = GitlabIngestor::new(
949            "alice".to_string(),
950            NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
951            NaiveDate::from_ymd_opt(2025, 2, 1).unwrap(),
952        );
953        ing.instance = "gitlab.com".to_string();
954        assert_eq!(ing.api_base_url(), "https://gitlab.com/api/v4");
955
956        ing.instance = "gitlab.company.com".to_string();
957        assert_eq!(ing.api_base_url(), "https://gitlab.company.com/api/v4");
958    }
959
960    #[test]
961    fn build_url_with_params_encodes_query_values() {
962        let url = build_url_with_params(
963            "https://gitlab.com/api/v4/projects",
964            &[
965                ("state", "opened".to_string()),
966                ("per_page", "100".to_string()),
967            ],
968        )
969        .unwrap();
970
971        let pairs: Vec<(String, String)> = url
972            .query_pairs()
973            .map(|(k, v)| (k.into_owned(), v.into_owned()))
974            .collect();
975        assert_eq!(
976            pairs,
977            vec![
978                ("state".to_string(), "opened".to_string()),
979                ("per_page".to_string(), "100".to_string()),
980            ]
981        );
982    }
983
984    // ── Property tests ──────────────────────────────────────────────────
985
986    proptest! {
987        #[test]
988        fn mr_state_roundtrips(variant in prop_oneof![
989            Just(MrState::Opened),
990            Just(MrState::Merged),
991            Just(MrState::Closed),
992            Just(MrState::All),
993        ]) {
994            let s = variant.as_str();
995            let parsed: MrState = s.parse().unwrap();
996            prop_assert_eq!(parsed, variant);
997        }
998
999        #[test]
1000        fn mr_state_parse_case_insensitive(
1001            variant in prop_oneof![
1002                Just("opened"), Just("OPENED"), Just("Opened"),
1003                Just("merged"), Just("MERGED"), Just("Merged"),
1004                Just("closed"), Just("CLOSED"), Just("Closed"),
1005                Just("all"), Just("ALL"), Just("All"),
1006            ]
1007        ) {
1008            let parsed = variant.parse::<MrState>();
1009            prop_assert!(parsed.is_ok());
1010        }
1011
1012        #[test]
1013        fn mr_state_invalid_always_errors(s in "[a-z]{6,10}") {
1014            // The 4 valid values are 3-6 chars; with 6-10 random chars
1015            // we avoid collisions with valid variants
1016            let parsed = s.parse::<MrState>();
1017            prop_assert!(parsed.is_err());
1018        }
1019
1020        #[test]
1021        fn build_url_with_params_never_panics(
1022            key in "[a-zA-Z_]{1,10}",
1023            value in "[ -~]{0,50}",
1024        ) {
1025            let result = build_url_with_params(
1026                "https://gitlab.com/api/v4/test",
1027                &[(&key, value)],
1028            );
1029            prop_assert!(result.is_ok());
1030        }
1031
1032        #[test]
1033        fn build_url_with_empty_params_is_identity(
1034            path in "/[a-z/]{1,30}",
1035        ) {
1036            let base = format!("https://gitlab.com/api/v4{}", path);
1037            let url = build_url_with_params(&base, &[]).unwrap();
1038            // No query string when params are empty
1039            prop_assert!(url.query().is_none());
1040        }
1041
1042        #[test]
1043        fn api_base_url_always_has_v4(hostname in "[a-z]{3,12}\\.[a-z]{2,6}") {
1044            let mut ing = default_ingestor();
1045            ing.instance = hostname;
1046            let base = ing.api_base_url();
1047            prop_assert!(base.ends_with("/api/v4"));
1048            prop_assert!(base.starts_with("https://"));
1049        }
1050
1051        #[test]
1052        fn builder_token_rejects_empty_accepts_nonempty(
1053            token in ".{1,50}"
1054        ) {
1055            let result = default_ingestor().with_token(token);
1056            prop_assert!(result.is_ok());
1057        }
1058    }
1059
1060    // ── API response deserialization tests ───────────────────────────────
1061
1062    #[test]
1063    fn deserialize_gitlab_user() {
1064        let json = r#"{"id": 42, "username": "alice"}"#;
1065        let user: GitlabUser = serde_json::from_str(json).unwrap();
1066        assert_eq!(user.id, 42);
1067        assert_eq!(user.username, "alice");
1068    }
1069
1070    #[test]
1071    fn deserialize_gitlab_project() {
1072        let json = r#"{
1073            "id": 7,
1074            "path_with_namespace": "org/myrepo",
1075            "public": false
1076        }"#;
1077        let project: GitlabProject = serde_json::from_str(json).unwrap();
1078        assert_eq!(project.id, 7);
1079        assert_eq!(project.path_with_namespace, "org/myrepo");
1080        assert!(!project.public);
1081    }
1082
1083    #[test]
1084    fn deserialize_gitlab_merge_request() {
1085        let mr: GitlabMergeRequest = serde_json::from_value(sample_mr_json()).unwrap();
1086        assert_eq!(mr.id, 101);
1087        assert_eq!(mr.iid, 42);
1088        assert_eq!(mr.project_id, 7);
1089        assert_eq!(mr.title, "Add feature X");
1090        assert_eq!(mr.state, "merged");
1091        assert_eq!(mr.additions, Some(120));
1092        assert_eq!(mr.deletions, Some(30));
1093        assert_eq!(mr.changed_files, Some(5));
1094        assert_eq!(mr.labels, vec!["backend", "feature"]);
1095        assert_eq!(mr.author.username, "alice");
1096        assert_eq!(mr.project.as_ref().unwrap().path_with_namespace, "org/repo");
1097        assert!(mr.merged_at.is_some());
1098        assert!(mr.closed_at.is_none());
1099    }
1100
1101    #[test]
1102    fn deserialize_mr_with_null_optional_fields() {
1103        let json = serde_json::json!({
1104            "id": 200,
1105            "iid": 10,
1106            "project_id": 3,
1107            "title": "Minimal MR",
1108            "state": "opened",
1109            "created_at": "2025-01-05T09:00:00Z",
1110            "merged_at": null,
1111            "closed_at": null,
1112            "additions": null,
1113            "deletions": null,
1114            "changed_files": null,
1115            "labels": [],
1116            "author": { "id": 1, "username": "alice" },
1117            "project": { "id": 3, "path_with_namespace": "org/minimal", "public": true }
1118        });
1119        let mr: GitlabMergeRequest = serde_json::from_value(json).unwrap();
1120        assert!(mr.merged_at.is_none());
1121        assert!(mr.additions.is_none());
1122        assert!(mr.deletions.is_none());
1123        assert!(mr.changed_files.is_none());
1124        assert!(mr.labels.is_empty());
1125    }
1126
1127    #[test]
1128    fn deserialize_gitlab_note() {
1129        let note: GitlabNote = serde_json::from_value(sample_note_json()).unwrap();
1130        assert_eq!(note.id, 501);
1131        assert!(!note.system);
1132        assert_eq!(note.author.username, "bob");
1133    }
1134
1135    #[test]
1136    fn deserialize_system_note() {
1137        let json = serde_json::json!({
1138            "id": 502,
1139            "system": true,
1140            "created_at": "2025-01-10T14:30:00Z",
1141            "author": { "id": 99, "username": "gitlab-bot" }
1142        });
1143        let note: GitlabNote = serde_json::from_value(json).unwrap();
1144        assert!(note.system);
1145    }
1146
1147    #[test]
1148    fn deserialize_gitlab_author() {
1149        let json = r#"{"id": 5, "username": "charlie"}"#;
1150        let author: GitlabAuthor = serde_json::from_str(json).unwrap();
1151        assert_eq!(author.id, 5);
1152        assert_eq!(author.username, "charlie");
1153    }
1154
1155    #[test]
1156    fn recorded_gitlab_merge_request_payload_deserializes_and_converts() {
1157        let mr_payload = serde_json::json!({
1158            "id": 424242,
1159            "iid": 42,
1160            "project_id": 3001,
1161            "title": "Reduce deploy rollback toil",
1162            "description": "Add preflight checks and rollback runbook links.",
1163            "state": "merged",
1164            "created_at": "2025-03-10T15:30:00Z",
1165            "updated_at": "2025-03-12T17:45:00Z",
1166            "merged_at": "2025-03-12T17:45:00Z",
1167            "closed_at": null,
1168            "target_branch": "main",
1169            "source_branch": "rollback-preflight",
1170            "labels": ["reliability", "deploys"],
1171            "author": {
1172                "id": 100,
1173                "name": "Alice Example",
1174                "username": "alice",
1175                "state": "active",
1176                "avatar_url": null,
1177                "web_url": "https://gitlab.example.com/alice"
1178            },
1179            "reviewers": [{
1180                "id": 101,
1181                "name": "Bob Reviewer",
1182                "username": "bob",
1183                "state": "active",
1184                "avatar_url": null,
1185                "web_url": "https://gitlab.example.com/bob"
1186            }],
1187            "source_project_id": 3001,
1188            "target_project_id": 3001,
1189            "references": {
1190                "short": "!42",
1191                "relative": "reliability!42",
1192                "full": "platform/reliability!42"
1193            },
1194            "web_url": "https://gitlab.example.com/platform/reliability/-/merge_requests/42",
1195            "user_notes_count": 3,
1196            "changes_count": "8",
1197            "time_stats": {
1198                "time_estimate": 0,
1199                "total_time_spent": 0,
1200                "human_time_estimate": null,
1201                "human_total_time_spent": null
1202            }
1203        });
1204
1205        let mr: GitlabMergeRequest = serde_json::from_value(mr_payload.clone()).unwrap();
1206        assert_eq!(mr.id, 424242);
1207        assert_eq!(mr.project_id, 3001);
1208        assert!(mr.project.is_none());
1209        assert_eq!(mr.project_path().unwrap(), "platform/reliability");
1210        assert!(!mr.project_public());
1211        assert_eq!(
1212            mr.web_url.as_deref(),
1213            Some("https://gitlab.example.com/platform/reliability/-/merge_requests/42")
1214        );
1215
1216        let mut ing = default_ingestor();
1217        ing.instance = "gitlab.example.com".to_string();
1218
1219        let events = ing
1220            .mrs_to_events(vec![serde_json::from_value(mr_payload).unwrap()])
1221            .unwrap();
1222        assert_eq!(events.len(), 1);
1223        let event = &events[0];
1224        assert_eq!(event.kind, EventKind::PullRequest);
1225        assert_eq!(event.actor.login, "alice");
1226        assert_eq!(event.actor.id, Some(100));
1227        assert_eq!(event.repo.full_name, "platform/reliability");
1228        assert_eq!(event.repo.visibility, RepoVisibility::Private);
1229        assert_eq!(
1230            event.source.system,
1231            SourceSystem::Other("gitlab".to_string())
1232        );
1233        assert_eq!(
1234            event.source.url.as_deref(),
1235            Some("https://gitlab.example.com/platform/reliability/-/merge_requests/42")
1236        );
1237        assert_eq!(event.source.opaque_id.as_deref(), Some("424242"));
1238        assert_eq!(event.tags, vec!["reliability", "deploys"]);
1239
1240        if let EventPayload::PullRequest(pr) = &event.payload {
1241            assert_eq!(pr.number, 42);
1242            assert_eq!(pr.title, "Reduce deploy rollback toil");
1243            assert_eq!(pr.state, PullRequestState::Merged);
1244            assert_eq!(
1245                pr.merged_at,
1246                Some("2025-03-12T17:45:00Z".parse::<DateTime<Utc>>().unwrap())
1247            );
1248            assert_eq!(pr.additions, None);
1249            assert_eq!(pr.deletions, None);
1250            assert_eq!(pr.changed_files, None);
1251        } else {
1252            panic!("Expected PullRequest payload");
1253        }
1254
1255        let notes_payload = serde_json::json!([
1256            {
1257                "id": 9001,
1258                "type": null,
1259                "body": "LGTM, the rollback path is clear.",
1260                "attachment": null,
1261                "author": {
1262                    "id": 101,
1263                    "name": "Bob Reviewer",
1264                    "username": "bob",
1265                    "state": "active",
1266                    "avatar_url": null,
1267                    "web_url": "https://gitlab.example.com/bob"
1268                },
1269                "created_at": "2025-03-12T16:30:00Z",
1270                "updated_at": "2025-03-12T16:30:00Z",
1271                "system": false,
1272                "noteable_id": 424242,
1273                "noteable_type": "MergeRequest",
1274                "project_id": 3001,
1275                "resolvable": false,
1276                "confidential": false,
1277                "internal": false,
1278                "noteable_iid": 42
1279            },
1280            {
1281                "id": 9002,
1282                "body": "approved this merge request",
1283                "author": { "id": 102, "username": "gitlab-bot" },
1284                "created_at": "2025-03-12T16:35:00Z",
1285                "system": true
1286            },
1287            {
1288                "id": 9003,
1289                "body": "Addressed follow-up.",
1290                "author": { "id": 100, "username": "alice" },
1291                "created_at": "2025-03-12T16:40:00Z",
1292                "system": false
1293            }
1294        ]);
1295        let notes: Vec<GitlabNote> = serde_json::from_value(notes_payload).unwrap();
1296        let review_events = ing.notes_to_review_events(notes, &mr).unwrap();
1297        assert_eq!(review_events.len(), 1);
1298        let review = &review_events[0];
1299        assert_eq!(review.kind, EventKind::Review);
1300        assert_eq!(review.actor.login, "bob");
1301        assert_eq!(review.repo.full_name, "platform/reliability");
1302        assert_eq!(
1303            review.source.url.as_deref(),
1304            Some("https://gitlab.example.com/platform/reliability/-/merge_requests/42#note_9001")
1305        );
1306        if let EventPayload::Review(payload) = &review.payload {
1307            assert_eq!(payload.pull_number, 42);
1308            assert_eq!(payload.pull_title, "Reduce deploy rollback toil");
1309            assert_eq!(payload.state, "approved");
1310        } else {
1311            panic!("Expected Review payload");
1312        }
1313    }
1314
1315    // ── mrs_to_events conversion tests ──────────────────────────────────
1316
1317    #[test]
1318    fn mrs_to_events_converts_merged_mr() {
1319        let ing = default_ingestor();
1320        let mr: GitlabMergeRequest = serde_json::from_value(sample_mr_json()).unwrap();
1321
1322        let events = ing.mrs_to_events(vec![mr]).unwrap();
1323        assert_eq!(events.len(), 1);
1324
1325        let ev = &events[0];
1326        assert_eq!(ev.kind, EventKind::PullRequest);
1327        assert_eq!(ev.actor.login, "alice");
1328        assert_eq!(ev.actor.id, Some(1));
1329        assert_eq!(ev.repo.full_name, "org/repo");
1330        assert_eq!(ev.repo.visibility, RepoVisibility::Public);
1331        assert_eq!(ev.tags, vec!["backend", "feature"]);
1332
1333        // Check source
1334        assert_eq!(ev.source.system, SourceSystem::Other("gitlab".to_string()));
1335        assert!(
1336            ev.source
1337                .url
1338                .as_ref()
1339                .unwrap()
1340                .contains("merge_requests/42")
1341        );
1342        assert_eq!(ev.source.opaque_id.as_deref(), Some("101"));
1343
1344        // Check links
1345        assert_eq!(ev.links.len(), 1);
1346        assert_eq!(ev.links[0].label, "GitLab MR");
1347        assert!(ev.links[0].url.contains("org/repo/-/merge_requests/42"));
1348
1349        // Check payload
1350        if let EventPayload::PullRequest(pr) = &ev.payload {
1351            assert_eq!(pr.number, 42);
1352            assert_eq!(pr.title, "Add feature X");
1353            assert_eq!(pr.state, PullRequestState::Merged);
1354            assert!(pr.merged_at.is_some());
1355            assert_eq!(pr.additions, Some(120));
1356            assert_eq!(pr.deletions, Some(30));
1357            assert_eq!(pr.changed_files, Some(5));
1358        } else {
1359            panic!("Expected PullRequest payload");
1360        }
1361    }
1362
1363    #[test]
1364    fn mrs_to_events_maps_all_states() {
1365        let ing = default_ingestor();
1366
1367        for (state_str, expected) in [
1368            ("opened", PullRequestState::Open),
1369            ("merged", PullRequestState::Merged),
1370            ("closed", PullRequestState::Closed),
1371            ("unknown_state", PullRequestState::Unknown),
1372        ] {
1373            let mut json = sample_mr_json();
1374            json["state"] = serde_json::json!(state_str);
1375            // Bump id to avoid duplicate EventId
1376            json["id"] = serde_json::json!(state_str.len() as u64 + 1000);
1377            let mr: GitlabMergeRequest = serde_json::from_value(json).unwrap();
1378            let events = ing.mrs_to_events(vec![mr]).unwrap();
1379            if let EventPayload::PullRequest(pr) = &events[0].payload {
1380                assert_eq!(pr.state, expected, "state mismatch for '{}'", state_str);
1381            }
1382        }
1383    }
1384
1385    #[test]
1386    fn mrs_to_events_private_visibility() {
1387        let ing = default_ingestor();
1388
1389        let mut json = sample_mr_json();
1390        json["project"]["public"] = serde_json::json!(false);
1391        let mr: GitlabMergeRequest = serde_json::from_value(json).unwrap();
1392        let events = ing.mrs_to_events(vec![mr]).unwrap();
1393        assert_eq!(events[0].repo.visibility, RepoVisibility::Private);
1394    }
1395
1396    #[test]
1397    fn mrs_to_events_empty_input() {
1398        let ing = default_ingestor();
1399        let events = ing.mrs_to_events(vec![]).unwrap();
1400        assert!(events.is_empty());
1401    }
1402
1403    #[test]
1404    fn mrs_to_events_custom_instance() {
1405        let mut ing = default_ingestor();
1406        ing.instance = "gitlab.internal.co".to_string();
1407
1408        let mr: GitlabMergeRequest = serde_json::from_value(sample_mr_json()).unwrap();
1409        let events = ing.mrs_to_events(vec![mr]).unwrap();
1410
1411        let url = events[0].links[0].url.as_str();
1412        assert!(url.starts_with("https://gitlab.internal.co/"));
1413    }
1414
1415    // ── notes_to_review_events conversion tests ─────────────────────────
1416
1417    #[test]
1418    fn notes_to_review_events_converts_non_system_non_author_notes() {
1419        let ing = default_ingestor();
1420        let mr: GitlabMergeRequest = serde_json::from_value(sample_mr_json()).unwrap();
1421        let note: GitlabNote = serde_json::from_value(sample_note_json()).unwrap();
1422
1423        let events = ing.notes_to_review_events(vec![note], &mr).unwrap();
1424        assert_eq!(events.len(), 1);
1425
1426        let ev = &events[0];
1427        assert_eq!(ev.kind, EventKind::Review);
1428        assert_eq!(ev.actor.login, "bob");
1429        assert!(ev.links[0].url.contains("#note_501"));
1430
1431        if let EventPayload::Review(rev) = &ev.payload {
1432            assert_eq!(rev.pull_number, 42);
1433            assert_eq!(rev.pull_title, "Add feature X");
1434            assert_eq!(rev.state, "approved");
1435        } else {
1436            panic!("Expected Review payload");
1437        }
1438    }
1439
1440    #[test]
1441    fn notes_to_review_events_skips_system_notes() {
1442        let ing = default_ingestor();
1443        let mr: GitlabMergeRequest = serde_json::from_value(sample_mr_json()).unwrap();
1444
1445        let system_note: GitlabNote = serde_json::from_value(serde_json::json!({
1446            "id": 600,
1447            "system": true,
1448            "created_at": "2025-01-10T15:00:00Z",
1449            "author": { "id": 2, "username": "bob" }
1450        }))
1451        .unwrap();
1452
1453        let events = ing.notes_to_review_events(vec![system_note], &mr).unwrap();
1454        assert!(events.is_empty());
1455    }
1456
1457    #[test]
1458    fn notes_to_review_events_skips_self_authored_notes() {
1459        let ing = default_ingestor(); // user = "alice"
1460        let mr: GitlabMergeRequest = serde_json::from_value(sample_mr_json()).unwrap();
1461
1462        let self_note: GitlabNote = serde_json::from_value(serde_json::json!({
1463            "id": 601,
1464            "system": false,
1465            "created_at": "2025-01-10T15:30:00Z",
1466            "author": { "id": 1, "username": "alice" }
1467        }))
1468        .unwrap();
1469
1470        let events = ing.notes_to_review_events(vec![self_note], &mr).unwrap();
1471        assert!(events.is_empty());
1472    }
1473
1474    #[test]
1475    fn notes_to_review_events_empty_input() {
1476        let ing = default_ingestor();
1477        let mr: GitlabMergeRequest = serde_json::from_value(sample_mr_json()).unwrap();
1478
1479        let events = ing.notes_to_review_events(vec![], &mr).unwrap();
1480        assert!(events.is_empty());
1481    }
1482
1483    #[test]
1484    fn notes_to_review_events_mixed_filtering() {
1485        let ing = default_ingestor(); // user = "alice"
1486        let mr: GitlabMergeRequest = serde_json::from_value(sample_mr_json()).unwrap();
1487
1488        let notes: Vec<GitlabNote> = serde_json::from_value(serde_json::json!([
1489            { "id": 700, "system": false, "created_at": "2025-01-10T10:00:00Z",
1490              "author": { "id": 2, "username": "bob" } },
1491            { "id": 701, "system": true, "created_at": "2025-01-10T11:00:00Z",
1492              "author": { "id": 2, "username": "bob" } },
1493            { "id": 702, "system": false, "created_at": "2025-01-10T12:00:00Z",
1494              "author": { "id": 1, "username": "alice" } },
1495            { "id": 703, "system": false, "created_at": "2025-01-10T13:00:00Z",
1496              "author": { "id": 3, "username": "charlie" } }
1497        ]))
1498        .unwrap();
1499
1500        let events = ing.notes_to_review_events(notes, &mr).unwrap();
1501        // Only bob (700) and charlie (703); system note 701 and self-note 702 filtered
1502        assert_eq!(events.len(), 2);
1503        assert_eq!(events[0].actor.login, "bob");
1504        assert_eq!(events[1].actor.login, "charlie");
1505    }
1506
1507    // ── URL construction tests ──────────────────────────────────────────
1508
1509    #[test]
1510    fn html_base_url_custom_instance() {
1511        let ing = default_ingestor()
1512            .with_instance("https://gitlab.myorg.io".to_string())
1513            .unwrap();
1514        assert_eq!(ing.html_base_url(), "https://gitlab.myorg.io");
1515    }
1516
1517    #[test]
1518    fn api_base_url_custom_instance() {
1519        let ing = default_ingestor()
1520            .with_instance("https://gitlab.myorg.io".to_string())
1521            .unwrap();
1522        assert_eq!(ing.api_base_url(), "https://gitlab.myorg.io/api/v4");
1523    }
1524
1525    #[test]
1526    fn build_url_with_no_params() {
1527        let url = build_url_with_params("https://gitlab.com/api/v4/projects", &[]).unwrap();
1528        assert_eq!(url.as_str(), "https://gitlab.com/api/v4/projects");
1529    }
1530
1531    #[test]
1532    fn build_url_with_special_chars_in_values() {
1533        let url = build_url_with_params(
1534            "https://gitlab.com/api/v4/projects",
1535            &[("search", "hello world & more".to_string())],
1536        )
1537        .unwrap();
1538        let pairs: Vec<_> = url.query_pairs().collect();
1539        assert_eq!(pairs[0].1, "hello world & more");
1540    }
1541
1542    #[test]
1543    fn project_path_from_mr_web_url_accepts_gitlab_url_forms() {
1544        assert_eq!(
1545            project_path_from_mr_web_url(
1546                "https://gitlab.example.com/platform/reliability/-/merge_requests/42"
1547            )
1548            .as_deref(),
1549            Some("platform/reliability")
1550        );
1551        assert_eq!(
1552            project_path_from_mr_web_url(
1553                "https://gitlab.example.com/platform/reliability/merge_requests/42"
1554            )
1555            .as_deref(),
1556            Some("platform/reliability")
1557        );
1558        assert_eq!(project_path_from_mr_web_url("not-a-url"), None);
1559    }
1560
1561    #[test]
1562    fn build_url_with_invalid_base_url_errors() {
1563        let result = build_url_with_params("not-a-url", &[]);
1564        assert!(result.is_err());
1565    }
1566
1567    // ── Builder / configuration tests ───────────────────────────────────
1568
1569    #[test]
1570    fn default_ingestor_has_expected_defaults() {
1571        let ing = default_ingestor();
1572        assert_eq!(ing.user, "alice");
1573        assert_eq!(ing.state, MrState::Merged);
1574        assert!(!ing.include_reviews);
1575        assert!(ing.fetch_details);
1576        assert_eq!(ing.throttle_ms, 0);
1577        assert!(ing.token.is_none());
1578        assert_eq!(ing.instance, "gitlab.com");
1579        assert!(ing.cache.is_none());
1580    }
1581
1582    #[test]
1583    fn with_state_updates_state() {
1584        let ing = default_ingestor().with_state(MrState::All);
1585        assert_eq!(ing.state, MrState::All);
1586    }
1587
1588    #[test]
1589    fn with_include_reviews_updates_flag() {
1590        let ing = default_ingestor().with_include_reviews(true);
1591        assert!(ing.include_reviews);
1592    }
1593
1594    #[test]
1595    fn with_throttle_updates_delay() {
1596        let ing = default_ingestor().with_throttle(500);
1597        assert_eq!(ing.throttle_ms, 500);
1598    }
1599
1600    #[test]
1601    fn with_token_stores_value() {
1602        let ing = default_ingestor()
1603            .with_token("glpat-abc123".to_string())
1604            .unwrap();
1605        assert_eq!(ing.token.as_deref(), Some("glpat-abc123"));
1606    }
1607
1608    #[test]
1609    fn with_instance_bare_hostname() {
1610        let ing = default_ingestor()
1611            .with_instance("gitlab.internal.co".to_string())
1612            .unwrap();
1613        assert_eq!(ing.instance, "gitlab.internal.co");
1614    }
1615
1616    // ── Error handling tests ────────────────────────────────────────────
1617
1618    #[test]
1619    fn ingest_rejects_equal_dates() {
1620        let same = NaiveDate::from_ymd_opt(2025, 6, 15).unwrap();
1621        let ing = GitlabIngestor::new("alice".to_string(), same, same);
1622        let err = ing.ingest().unwrap_err();
1623        assert!(err.to_string().contains("since must be < until"));
1624    }
1625
1626    #[test]
1627    fn ingest_rejects_reversed_dates() {
1628        let ing = GitlabIngestor::new(
1629            "alice".to_string(),
1630            NaiveDate::from_ymd_opt(2025, 6, 1).unwrap(),
1631            NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
1632        );
1633        let err = ing.ingest().unwrap_err();
1634        assert!(err.to_string().contains("since must be < until"));
1635    }
1636
1637    #[test]
1638    fn ingest_requires_token() {
1639        let ing = default_ingestor(); // no token set
1640        let err = ing.ingest().unwrap_err();
1641        assert!(err.to_string().contains("token is required"));
1642    }
1643
1644    #[test]
1645    fn deserialize_mr_missing_required_field_errors() {
1646        let json = serde_json::json!({
1647            "id": 101,
1648            // missing "iid", "project_id", "title", etc.
1649        });
1650        let result = serde_json::from_value::<GitlabMergeRequest>(json);
1651        assert!(result.is_err());
1652    }
1653
1654    #[test]
1655    fn deserialize_note_missing_required_field_errors() {
1656        let json = serde_json::json!({
1657            "id": 501,
1658            // missing "system", "created_at", "author"
1659        });
1660        let result = serde_json::from_value::<GitlabNote>(json);
1661        assert!(result.is_err());
1662    }
1663
1664    #[test]
1665    fn deserialize_user_missing_required_field_errors() {
1666        let json = serde_json::json!({
1667            "id": 42
1668            // missing "username"
1669        });
1670        let result = serde_json::from_value::<GitlabUser>(json);
1671        assert!(result.is_err());
1672    }
1673}