1use 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#[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
92pub 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 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 pub fn with_progress(mut self, progress: MigrationProgress) -> Self {
131 self.progress = progress;
132 self
133 }
134
135 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 let project_path = &self.config.source_repo;
144
145 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 let owner = project_path
152 .rsplit('/')
153 .nth(1)
154 .unwrap_or("unknown")
155 .to_string();
156
157 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 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 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 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 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 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 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 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 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 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 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 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 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 Ok(false)
657 }
658}
659
660mod 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}