Skip to main content

guts_migrate/
gitlab.rs

1//! GitLab migration implementation.
2
3use crate::client::{
4    CreateIssueRequest, CreatePullRequestRequest, CreateReleaseRequest, GutsClient,
5};
6use crate::error::{MigrationError, Result};
7use crate::progress::{MigrationPhase, MigrationProgress};
8use crate::types::{MigrationConfig, MigrationOptions, MigrationReport};
9
10use reqwest::Client;
11use serde::Deserialize;
12use std::process::Command;
13use tempfile::TempDir;
14use tracing::{debug, info, warn};
15
16/// GitLab API response types
17#[derive(Debug, Deserialize)]
18#[allow(dead_code)]
19struct GitLabProject {
20    id: u64,
21    name: String,
22    path: String,
23    description: Option<String>,
24    visibility: String,
25    http_url_to_repo: String,
26    default_branch: Option<String>,
27    wiki_enabled: bool,
28}
29
30#[derive(Debug, Deserialize)]
31struct GitLabIssue {
32    iid: u64,
33    title: String,
34    description: Option<String>,
35    state: String,
36    labels: Vec<String>,
37    author: GitLabUser,
38}
39
40#[derive(Debug, Deserialize)]
41struct GitLabMergeRequest {
42    iid: u64,
43    title: String,
44    description: Option<String>,
45    state: String,
46    source_branch: String,
47    target_branch: String,
48    author: GitLabUser,
49}
50
51#[derive(Debug, Deserialize)]
52#[allow(dead_code)]
53struct GitLabRelease {
54    tag_name: String,
55    name: Option<String>,
56    description: Option<String>,
57    assets: GitLabAssets,
58}
59
60#[derive(Debug, Deserialize)]
61#[allow(dead_code)]
62struct GitLabAssets {
63    links: Vec<GitLabAssetLink>,
64}
65
66#[derive(Debug, Deserialize)]
67#[allow(dead_code)]
68struct GitLabAssetLink {
69    name: String,
70    url: String,
71}
72
73#[derive(Debug, Deserialize)]
74struct GitLabUser {
75    username: String,
76}
77
78#[derive(Debug, Deserialize)]
79struct GitLabLabel {
80    name: String,
81    color: String,
82    description: Option<String>,
83}
84
85#[derive(Debug, Deserialize)]
86#[allow(dead_code)]
87struct GitLabNote {
88    body: String,
89    author: GitLabUser,
90}
91
92/// Migrator for GitLab projects.
93pub struct GitLabMigrator {
94    gitlab_client: Client,
95    gitlab_token: String,
96    gitlab_url: String,
97    guts_client: GutsClient,
98    config: MigrationConfig,
99    progress: MigrationProgress,
100}
101
102impl GitLabMigrator {
103    /// Create a new GitLab migrator.
104    ///
105    /// # Arguments
106    ///
107    /// * `gitlab_token` - GitLab personal access token
108    /// * `gitlab_url` - GitLab instance URL (e.g., "https://gitlab.com")
109    /// * `config` - Migration configuration
110    pub fn new(gitlab_token: &str, gitlab_url: &str, config: MigrationConfig) -> Result<Self> {
111        let gitlab_client = Client::builder()
112            .user_agent("guts-migrate")
113            .timeout(std::time::Duration::from_secs(30))
114            .build()
115            .map_err(|e| MigrationError::NetworkError(e.to_string()))?;
116
117        let guts_client = GutsClient::new(&config.guts_url, config.guts_token.clone())?;
118
119        Ok(Self {
120            gitlab_client,
121            gitlab_token: gitlab_token.to_string(),
122            gitlab_url: gitlab_url.trim_end_matches('/').to_string(),
123            guts_client,
124            config,
125            progress: MigrationProgress::new(),
126        })
127    }
128
129    /// Set a progress callback.
130    pub fn with_progress(mut self, progress: MigrationProgress) -> Self {
131        self.progress = progress;
132        self
133    }
134
135    /// Run the migration.
136    pub async fn migrate(&self, options: MigrationOptions) -> Result<MigrationReport> {
137        let mut report = MigrationReport::new();
138
139        info!("Starting GitLab migration for {}", self.config.source_repo);
140        self.progress.set_phase(MigrationPhase::Initializing, 1);
141
142        // Parse project path (can be nested groups: group/subgroup/project)
143        let project_path = &self.config.source_repo;
144
145        // Step 1: Fetch project info from GitLab
146        self.progress.message("Fetching project information...");
147        let gl_project = self.fetch_project_info(project_path).await?;
148        debug!("Fetched project info: {:?}", gl_project.name);
149
150        // Extract owner from project path
151        let owner = project_path
152            .rsplit('/')
153            .nth(1)
154            .unwrap_or("unknown")
155            .to_string();
156
157        // Step 2: Create repository on Guts
158        self.progress
159            .set_phase(MigrationPhase::CreatingRepository, 1);
160        let target_owner = self.config.target_owner.as_deref().unwrap_or(&owner);
161        let target_name = self
162            .config
163            .target_name
164            .as_deref()
165            .unwrap_or(&gl_project.name);
166        let is_private = gl_project.visibility != "public";
167
168        match self
169            .guts_client
170            .create_repo(target_name, gl_project.description.as_deref(), is_private)
171            .await
172        {
173            Ok(guts_repo) => {
174                report.repo_created = true;
175                report.guts_repo_url = Some(guts_repo.clone_url.clone());
176                info!("Created repository on Guts: {}", guts_repo.clone_url);
177            }
178            Err(e) => {
179                report.add_error("repository", &e.to_string(), true);
180                return Ok(report);
181            }
182        }
183
184        // Step 3: Mirror Git repository
185        self.progress
186            .set_phase(MigrationPhase::CloningRepository, 1);
187        match self
188            .mirror_git_repo(&gl_project, target_owner, target_name)
189            .await
190        {
191            Ok((branches, tags)) => {
192                report.git_mirrored = true;
193                report.branches_migrated = branches;
194                report.tags_migrated = tags;
195                info!("Git repository mirrored successfully");
196            }
197            Err(e) => {
198                report.add_error("git", &e.to_string(), true);
199                return Ok(report);
200            }
201        }
202
203        // Step 4: Migrate labels
204        if options.migrate_labels {
205            self.progress.set_phase(MigrationPhase::MigratingLabels, 1);
206            match self
207                .migrate_labels(gl_project.id, target_owner, target_name)
208                .await
209            {
210                Ok(count) => {
211                    report.labels_migrated = count;
212                    info!("Migrated {count} labels");
213                }
214                Err(e) => {
215                    report.add_error("labels", &e.to_string(), false);
216                    warn!("Failed to migrate labels: {e}");
217                }
218            }
219        }
220
221        // Step 5: Migrate issues
222        if options.migrate_issues {
223            match self
224                .migrate_issues(gl_project.id, target_owner, target_name, &options)
225                .await
226            {
227                Ok(count) => {
228                    report.issues_migrated = count;
229                    info!("Migrated {count} issues");
230                }
231                Err(e) => {
232                    report.add_error("issues", &e.to_string(), false);
233                    warn!("Failed to migrate issues: {e}");
234                }
235            }
236        }
237
238        // Step 6: Migrate merge requests
239        if options.migrate_pull_requests {
240            match self
241                .migrate_merge_requests(gl_project.id, target_owner, target_name, &options)
242                .await
243            {
244                Ok(count) => {
245                    report.prs_migrated = count;
246                    info!("Migrated {count} merge requests");
247                }
248                Err(e) => {
249                    report.add_error("merge_requests", &e.to_string(), false);
250                    warn!("Failed to migrate merge requests: {e}");
251                }
252            }
253        }
254
255        // Step 7: Migrate releases
256        if options.migrate_releases {
257            match self
258                .migrate_releases(gl_project.id, target_owner, target_name)
259                .await
260            {
261                Ok((releases, assets)) => {
262                    report.releases_migrated = releases;
263                    report.assets_migrated = assets;
264                    info!("Migrated {releases} releases with {assets} assets");
265                }
266                Err(e) => {
267                    report.add_error("releases", &e.to_string(), false);
268                    warn!("Failed to migrate releases: {e}");
269                }
270            }
271        }
272
273        // Step 8: Migrate wiki (if available)
274        if options.migrate_wiki && gl_project.wiki_enabled {
275            self.progress.set_phase(MigrationPhase::MigratingWiki, 1);
276            match self
277                .migrate_wiki(&gl_project, target_owner, target_name)
278                .await
279            {
280                Ok(migrated) => {
281                    report.wiki_migrated = migrated;
282                    if migrated {
283                        info!("Wiki migrated successfully");
284                    }
285                }
286                Err(e) => {
287                    report.add_warning(format!("Wiki migration skipped: {e}"));
288                    warn!("Failed to migrate wiki: {e}");
289                }
290            }
291        }
292
293        self.progress.set_phase(MigrationPhase::Complete, 1);
294        report.complete();
295
296        Ok(report)
297    }
298
299    async fn gitlab_get<T: serde::de::DeserializeOwned>(&self, path: &str) -> Result<T> {
300        let url = format!("{}/api/v4{path}", self.gitlab_url);
301        let response = self
302            .gitlab_client
303            .get(&url)
304            .header("PRIVATE-TOKEN", &self.gitlab_token)
305            .send()
306            .await
307            .map_err(|e| MigrationError::NetworkError(e.to_string()))?;
308
309        if response.status() == 404 {
310            return Err(MigrationError::RepositoryNotFound(path.to_string()));
311        }
312
313        if response.status() == 401 {
314            return Err(MigrationError::AuthenticationFailed(
315                "Invalid GitLab token".to_string(),
316            ));
317        }
318
319        if !response.status().is_success() {
320            let status = response.status();
321            let body = response.text().await.unwrap_or_default();
322            return Err(MigrationError::ApiError(format!(
323                "GitLab API error ({status}): {body}"
324            )));
325        }
326
327        response
328            .json()
329            .await
330            .map_err(|e| MigrationError::ApiError(e.to_string()))
331    }
332
333    async fn gitlab_get_paginated<T: serde::de::DeserializeOwned>(
334        &self,
335        path: &str,
336    ) -> Result<Vec<T>> {
337        let mut all_items = Vec::new();
338        let mut page = 1;
339
340        loop {
341            let paginated_path = if path.contains('?') {
342                format!("{path}&page={page}&per_page=100")
343            } else {
344                format!("{path}?page={page}&per_page=100")
345            };
346
347            let items: Vec<T> = self.gitlab_get(&paginated_path).await?;
348
349            if items.is_empty() {
350                break;
351            }
352
353            let count = items.len();
354            all_items.extend(items);
355
356            if count < 100 {
357                break;
358            }
359            page += 1;
360        }
361
362        Ok(all_items)
363    }
364
365    async fn fetch_project_info(&self, project_path: &str) -> Result<GitLabProject> {
366        let encoded_path = urlencoding::encode(project_path);
367        self.gitlab_get(&format!("/projects/{encoded_path}")).await
368    }
369
370    async fn mirror_git_repo(
371        &self,
372        gl_project: &GitLabProject,
373        target_owner: &str,
374        target_name: &str,
375    ) -> Result<(usize, usize)> {
376        let temp_dir = TempDir::new()?;
377        let clone_path = temp_dir.path().join("repo");
378
379        // Clone with all branches and tags (mirror)
380        // GitLab uses oauth2:TOKEN format for authentication
381        let clone_url = gl_project.http_url_to_repo.replace(
382            "https://",
383            &format!("https://oauth2:{}@", self.gitlab_token),
384        );
385
386        let output = Command::new("git")
387            .args(["clone", "--mirror", &clone_url])
388            .arg(&clone_path)
389            .output()?;
390
391        if !output.status.success() {
392            return Err(MigrationError::GitCloneFailed(
393                String::from_utf8_lossy(&output.stderr).to_string(),
394            ));
395        }
396
397        // Count branches and tags
398        let branches_output = Command::new("git")
399            .current_dir(&clone_path)
400            .args(["branch", "-r"])
401            .output()?;
402        let branches = String::from_utf8_lossy(&branches_output.stdout)
403            .lines()
404            .filter(|l| !l.is_empty())
405            .count();
406
407        let tags_output = Command::new("git")
408            .current_dir(&clone_path)
409            .args(["tag"])
410            .output()?;
411        let tags = String::from_utf8_lossy(&tags_output.stdout)
412            .lines()
413            .filter(|l| !l.is_empty())
414            .count();
415
416        // Push to Guts
417        self.progress
418            .set_phase(MigrationPhase::PushingRepository, 1);
419
420        let guts_url = format!(
421            "{}/git/{}/{}.git",
422            self.config.guts_url, target_owner, target_name
423        );
424
425        let output = Command::new("git")
426            .current_dir(&clone_path)
427            .args(["push", "--mirror", &guts_url])
428            .output()?;
429
430        if !output.status.success() {
431            return Err(MigrationError::GitPushFailed(
432                String::from_utf8_lossy(&output.stderr).to_string(),
433            ));
434        }
435
436        Ok((branches, tags))
437    }
438
439    async fn migrate_labels(
440        &self,
441        project_id: u64,
442        target_owner: &str,
443        target_name: &str,
444    ) -> Result<usize> {
445        let labels: Vec<GitLabLabel> = self
446            .gitlab_get_paginated(&format!("/projects/{project_id}/labels"))
447            .await?;
448
449        self.progress
450            .set_phase(MigrationPhase::MigratingLabels, labels.len() as u64);
451
452        let mut count = 0;
453        for label in &labels {
454            // GitLab colors start with #, strip it for Guts
455            let color = label.color.trim_start_matches('#');
456
457            match self
458                .guts_client
459                .create_label(
460                    target_owner,
461                    target_name,
462                    &label.name,
463                    color,
464                    label.description.as_deref(),
465                )
466                .await
467            {
468                Ok(()) => {
469                    count += 1;
470                    self.progress.increment(Some(&label.name));
471                }
472                Err(e) => {
473                    debug!("Failed to create label {}: {e}", label.name);
474                }
475            }
476        }
477
478        Ok(count)
479    }
480
481    async fn migrate_issues(
482        &self,
483        project_id: u64,
484        target_owner: &str,
485        target_name: &str,
486        options: &MigrationOptions,
487    ) -> Result<usize> {
488        let state = if options.include_closed {
489            "all"
490        } else {
491            "opened"
492        };
493        let issues: Vec<GitLabIssue> = self
494            .gitlab_get_paginated(&format!("/projects/{project_id}/issues?state={state}"))
495            .await?;
496
497        self.progress
498            .set_phase(MigrationPhase::MigratingIssues, issues.len() as u64);
499
500        let mut count = 0;
501        for issue in &issues {
502            let body = issue.description.as_deref().unwrap_or("");
503            let body_with_note = format!(
504                "{body}\n\n---\n*Migrated from GitLab issue #{} by @{}*",
505                issue.iid, issue.author.username
506            );
507
508            match self
509                .guts_client
510                .create_issue(
511                    target_owner,
512                    target_name,
513                    &CreateIssueRequest {
514                        title: issue.title.clone(),
515                        body: Some(body_with_note),
516                        labels: issue.labels.clone(),
517                        assignees: vec![],
518                    },
519                )
520                .await
521            {
522                Ok(guts_issue) => {
523                    // Close if closed on GitLab
524                    if issue.state == "closed" {
525                        let _ = self
526                            .guts_client
527                            .close_issue(target_owner, target_name, guts_issue.number)
528                            .await;
529                    }
530
531                    count += 1;
532                    self.progress
533                        .increment(Some(&format!("Issue #{}", issue.iid)));
534                }
535                Err(e) => {
536                    debug!("Failed to create issue #{}: {e}", issue.iid);
537                }
538            }
539        }
540
541        Ok(count)
542    }
543
544    async fn migrate_merge_requests(
545        &self,
546        project_id: u64,
547        target_owner: &str,
548        target_name: &str,
549        options: &MigrationOptions,
550    ) -> Result<usize> {
551        let state = if options.include_closed {
552            "all"
553        } else {
554            "opened"
555        };
556        let mrs: Vec<GitLabMergeRequest> = self
557            .gitlab_get_paginated(&format!(
558                "/projects/{project_id}/merge_requests?state={state}"
559            ))
560            .await?;
561
562        self.progress
563            .set_phase(MigrationPhase::MigratingPullRequests, mrs.len() as u64);
564
565        let mut count = 0;
566        for mr in &mrs {
567            let body = mr.description.as_deref().unwrap_or("");
568            let body_with_note = format!(
569                "{body}\n\n---\n*Migrated from GitLab MR !{} ({}) by @{}*",
570                mr.iid, mr.state, mr.author.username
571            );
572
573            match self
574                .guts_client
575                .create_pull_request(
576                    target_owner,
577                    target_name,
578                    &CreatePullRequestRequest {
579                        title: mr.title.clone(),
580                        body: Some(body_with_note),
581                        source_branch: mr.source_branch.clone(),
582                        target_branch: mr.target_branch.clone(),
583                    },
584                )
585                .await
586            {
587                Ok(_guts_pr) => {
588                    count += 1;
589                    self.progress.increment(Some(&format!("MR !{}", mr.iid)));
590                }
591                Err(e) => {
592                    debug!("Failed to create MR !{}: {e}", mr.iid);
593                }
594            }
595        }
596
597        Ok(count)
598    }
599
600    async fn migrate_releases(
601        &self,
602        project_id: u64,
603        target_owner: &str,
604        target_name: &str,
605    ) -> Result<(usize, usize)> {
606        let releases: Vec<GitLabRelease> = self
607            .gitlab_get_paginated(&format!("/projects/{project_id}/releases"))
608            .await?;
609
610        self.progress
611            .set_phase(MigrationPhase::MigratingReleases, releases.len() as u64);
612
613        let mut release_count = 0;
614        let asset_count = 0;
615
616        for release in &releases {
617            match self
618                .guts_client
619                .create_release(
620                    target_owner,
621                    target_name,
622                    &CreateReleaseRequest {
623                        tag_name: release.tag_name.clone(),
624                        name: release
625                            .name
626                            .clone()
627                            .unwrap_or_else(|| release.tag_name.clone()),
628                        body: release.description.clone(),
629                        prerelease: Some(false),
630                        draft: Some(false),
631                    },
632                )
633                .await
634            {
635                Ok(_guts_release) => {
636                    // TODO: Download and upload assets
637                    release_count += 1;
638                    self.progress.increment(Some(&release.tag_name));
639                }
640                Err(e) => {
641                    debug!("Failed to create release {}: {e}", release.tag_name);
642                }
643            }
644        }
645
646        Ok((release_count, asset_count))
647    }
648
649    async fn migrate_wiki(
650        &self,
651        _gl_project: &GitLabProject,
652        _target_owner: &str,
653        _target_name: &str,
654    ) -> Result<bool> {
655        // TODO: Implement wiki migration
656        Ok(false)
657    }
658}
659
660// URL encoding helper
661mod urlencoding {
662    pub fn encode(s: &str) -> String {
663        s.replace('/', "%2F")
664    }
665}
666
667#[cfg(test)]
668mod tests {
669    use super::*;
670
671    #[test]
672    fn test_url_encoding() {
673        assert_eq!(urlencoding::encode("group/project"), "group%2Fproject");
674        assert_eq!(
675            urlencoding::encode("group/subgroup/project"),
676            "group%2Fsubgroup%2Fproject"
677        );
678    }
679}