1use 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#[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 pub instance: String,
70 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 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 pub fn with_instance(mut self, instance: String) -> Result<Self> {
101 if instance.is_empty() {
103 return Err(anyhow!("GitLab instance cannot be empty"));
104 }
105
106 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 pub fn with_state(mut self, state: MrState) -> Self {
122 self.state = state;
123 self
124 }
125
126 pub fn with_include_reviews(mut self, include: bool) -> Self {
128 self.include_reviews = include;
129 self
130 }
131
132 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 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 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 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 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 #[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 #[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 #[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 if self.state != MrState::All {
299 params.push(("state", self.state.as_str().to_string()));
300 }
301
302 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, ¶ms) {
309 Ok(mrs) => mrs,
310 Err(e) => {
311 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 #[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, ¬es)?;
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 #[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 #[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 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", ¬e.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 let user_id = self.get_user_id(&client)?;
558
559 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 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 events.extend(self.mrs_to_events(mrs)?);
575
576 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 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#[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 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 #[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), ¬es)
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 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 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 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 #[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 #[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 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 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 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 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 #[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(); 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(); 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 assert_eq!(events.len(), 2);
1503 assert_eq!(events[0].actor.login, "bob");
1504 assert_eq!(events[1].actor.login, "charlie");
1505 }
1506
1507 #[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 #[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 #[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(); 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 });
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 });
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 });
1670 let result = serde_json::from_value::<GitlabUser>(json);
1671 assert!(result.is_err());
1672 }
1673}