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 GitHubRepo {
20 name: String,
21 description: Option<String>,
22 private: bool,
23 clone_url: String,
24 has_wiki: bool,
25 default_branch: String,
26}
27
28#[derive(Debug, Deserialize)]
29struct GitHubIssue {
30 number: u64,
31 title: String,
32 body: Option<String>,
33 state: String,
34 labels: Vec<GitHubLabel>,
35 user: GitHubUser,
36}
37
38#[derive(Debug, Deserialize)]
39struct GitHubPullRequest {
40 number: u64,
41 title: String,
42 body: Option<String>,
43 state: String,
44 merged: bool,
45 head: GitHubRef,
46 base: GitHubRef,
47 user: GitHubUser,
48}
49
50#[derive(Debug, Deserialize)]
51#[allow(dead_code)]
52struct GitHubRef {
53 #[serde(rename = "ref")]
54 ref_name: String,
55 sha: String,
56}
57
58#[derive(Debug, Deserialize)]
59struct GitHubLabel {
60 name: String,
61 color: String,
62 description: Option<String>,
63}
64
65#[derive(Debug, Deserialize)]
66struct GitHubRelease {
67 tag_name: String,
68 name: Option<String>,
69 body: Option<String>,
70 prerelease: bool,
71 draft: bool,
72 assets: Vec<GitHubAsset>,
73}
74
75#[derive(Debug, Deserialize)]
76#[allow(dead_code)]
77struct GitHubAsset {
78 name: String,
79 content_type: String,
80 browser_download_url: String,
81 size: u64,
82}
83
84#[derive(Debug, Deserialize)]
85struct GitHubUser {
86 login: String,
87}
88
89#[derive(Debug, Deserialize)]
90struct GitHubComment {
91 body: String,
92 user: GitHubUser,
93}
94
95pub struct GitHubMigrator {
97 github_client: Client,
98 github_token: String,
99 guts_client: GutsClient,
100 config: MigrationConfig,
101 progress: MigrationProgress,
102}
103
104impl GitHubMigrator {
105 pub fn new(github_token: &str, config: MigrationConfig) -> Result<Self> {
107 let github_client = Client::builder()
108 .user_agent("guts-migrate")
109 .timeout(std::time::Duration::from_secs(30))
110 .build()
111 .map_err(|e| MigrationError::NetworkError(e.to_string()))?;
112
113 let guts_client = GutsClient::new(&config.guts_url, config.guts_token.clone())?;
114
115 Ok(Self {
116 github_client,
117 github_token: github_token.to_string(),
118 guts_client,
119 config,
120 progress: MigrationProgress::new(),
121 })
122 }
123
124 pub fn with_progress(mut self, progress: MigrationProgress) -> Self {
126 self.progress = progress;
127 self
128 }
129
130 pub async fn migrate(&self, options: MigrationOptions) -> Result<MigrationReport> {
132 let mut report = MigrationReport::new();
133
134 info!("Starting GitHub migration for {}", self.config.source_repo);
135 self.progress.set_phase(MigrationPhase::Initializing, 1);
136
137 let (owner, repo_name) = self.parse_repo()?;
139
140 self.progress.message("Fetching repository information...");
142 let gh_repo = self.fetch_repo_info(&owner, &repo_name).await?;
143 debug!("Fetched repo info: {:?}", gh_repo.name);
144
145 self.progress
147 .set_phase(MigrationPhase::CreatingRepository, 1);
148 let target_owner = self.config.target_owner.as_deref().unwrap_or(&owner);
149 let target_name = self.config.target_name.as_deref().unwrap_or(&gh_repo.name);
150
151 match self
152 .guts_client
153 .create_repo(target_name, gh_repo.description.as_deref(), gh_repo.private)
154 .await
155 {
156 Ok(guts_repo) => {
157 report.repo_created = true;
158 report.guts_repo_url = Some(guts_repo.clone_url.clone());
159 info!("Created repository on Guts: {}", guts_repo.clone_url);
160 }
161 Err(e) => {
162 report.add_error("repository", &e.to_string(), true);
163 return Ok(report);
164 }
165 }
166
167 self.progress
169 .set_phase(MigrationPhase::CloningRepository, 1);
170 match self
171 .mirror_git_repo(&gh_repo, target_owner, target_name)
172 .await
173 {
174 Ok((branches, tags)) => {
175 report.git_mirrored = true;
176 report.branches_migrated = branches;
177 report.tags_migrated = tags;
178 info!("Git repository mirrored successfully");
179 }
180 Err(e) => {
181 report.add_error("git", &e.to_string(), true);
182 return Ok(report);
183 }
184 }
185
186 if options.migrate_labels {
188 self.progress.set_phase(MigrationPhase::MigratingLabels, 1);
189 match self
190 .migrate_labels(&owner, &repo_name, target_owner, target_name)
191 .await
192 {
193 Ok(count) => {
194 report.labels_migrated = count;
195 info!("Migrated {count} labels");
196 }
197 Err(e) => {
198 report.add_error("labels", &e.to_string(), false);
199 warn!("Failed to migrate labels: {e}");
200 }
201 }
202 }
203
204 if options.migrate_issues {
206 match self
207 .migrate_issues(&owner, &repo_name, target_owner, target_name, &options)
208 .await
209 {
210 Ok(count) => {
211 report.issues_migrated = count;
212 info!("Migrated {count} issues");
213 }
214 Err(e) => {
215 report.add_error("issues", &e.to_string(), false);
216 warn!("Failed to migrate issues: {e}");
217 }
218 }
219 }
220
221 if options.migrate_pull_requests {
223 match self
224 .migrate_pull_requests(&owner, &repo_name, target_owner, target_name, &options)
225 .await
226 {
227 Ok(count) => {
228 report.prs_migrated = count;
229 info!("Migrated {count} pull requests");
230 }
231 Err(e) => {
232 report.add_error("pull_requests", &e.to_string(), false);
233 warn!("Failed to migrate pull requests: {e}");
234 }
235 }
236 }
237
238 if options.migrate_releases {
240 match self
241 .migrate_releases(&owner, &repo_name, target_owner, target_name)
242 .await
243 {
244 Ok((releases, assets)) => {
245 report.releases_migrated = releases;
246 report.assets_migrated = assets;
247 info!("Migrated {releases} releases with {assets} assets");
248 }
249 Err(e) => {
250 report.add_error("releases", &e.to_string(), false);
251 warn!("Failed to migrate releases: {e}");
252 }
253 }
254 }
255
256 if options.migrate_wiki && gh_repo.has_wiki {
258 self.progress.set_phase(MigrationPhase::MigratingWiki, 1);
259 match self
260 .migrate_wiki(&owner, &repo_name, target_owner, target_name)
261 .await
262 {
263 Ok(migrated) => {
264 report.wiki_migrated = migrated;
265 if migrated {
266 info!("Wiki migrated successfully");
267 }
268 }
269 Err(e) => {
270 report.add_warning(format!("Wiki migration skipped: {e}"));
271 warn!("Failed to migrate wiki: {e}");
272 }
273 }
274 }
275
276 self.progress.set_phase(MigrationPhase::Complete, 1);
277 report.complete();
278
279 Ok(report)
280 }
281
282 fn parse_repo(&self) -> Result<(String, String)> {
283 let parts: Vec<&str> = self.config.source_repo.split('/').collect();
284 if parts.len() != 2 {
285 return Err(MigrationError::InvalidConfig(format!(
286 "Invalid repository format: {}. Expected 'owner/repo'",
287 self.config.source_repo
288 )));
289 }
290 Ok((parts[0].to_string(), parts[1].to_string()))
291 }
292
293 async fn github_get<T: serde::de::DeserializeOwned>(&self, url: &str) -> Result<T> {
294 let response = self
295 .github_client
296 .get(url)
297 .header("Authorization", format!("Bearer {}", self.github_token))
298 .header("Accept", "application/vnd.github.v3+json")
299 .send()
300 .await
301 .map_err(|e| MigrationError::NetworkError(e.to_string()))?;
302
303 if response.status() == 404 {
304 return Err(MigrationError::RepositoryNotFound(url.to_string()));
305 }
306
307 if response.status() == 403 {
308 if let Some(reset) = response.headers().get("x-ratelimit-reset") {
310 if let Ok(reset_time) = reset.to_str().unwrap_or("0").parse::<u64>() {
311 let now = std::time::SystemTime::now()
312 .duration_since(std::time::UNIX_EPOCH)
313 .unwrap()
314 .as_secs();
315 if reset_time > now {
316 return Err(MigrationError::RateLimitExceeded(reset_time - now));
317 }
318 }
319 }
320 return Err(MigrationError::AuthenticationFailed(
321 "Access denied. Check your token permissions.".to_string(),
322 ));
323 }
324
325 if !response.status().is_success() {
326 let status = response.status();
327 let body = response.text().await.unwrap_or_default();
328 return Err(MigrationError::ApiError(format!(
329 "GitHub API error ({status}): {body}"
330 )));
331 }
332
333 response
334 .json()
335 .await
336 .map_err(|e| MigrationError::ApiError(e.to_string()))
337 }
338
339 async fn github_get_paginated<T: serde::de::DeserializeOwned>(
340 &self,
341 base_url: &str,
342 ) -> Result<Vec<T>> {
343 let mut all_items = Vec::new();
344 let mut page = 1;
345
346 loop {
347 let url = format!("{base_url}?page={page}&per_page=100");
348 let items: Vec<T> = self.github_get(&url).await?;
349
350 if items.is_empty() {
351 break;
352 }
353
354 let count = items.len();
355 all_items.extend(items);
356
357 if count < 100 {
358 break;
359 }
360 page += 1;
361 }
362
363 Ok(all_items)
364 }
365
366 async fn fetch_repo_info(&self, owner: &str, repo: &str) -> Result<GitHubRepo> {
367 let url = format!("https://api.github.com/repos/{owner}/{repo}");
368 self.github_get(&url).await
369 }
370
371 async fn mirror_git_repo(
372 &self,
373 _gh_repo: &GitHubRepo,
374 target_owner: &str,
375 target_name: &str,
376 ) -> Result<(usize, usize)> {
377 let temp_dir = TempDir::new()?;
378 let clone_path = temp_dir.path().join("repo");
379
380 let clone_url = format!(
382 "https://{}@github.com/{}.git",
383 self.github_token, self.config.source_repo
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 owner: &str,
442 repo: &str,
443 target_owner: &str,
444 target_name: &str,
445 ) -> Result<usize> {
446 let url = format!("https://api.github.com/repos/{owner}/{repo}/labels");
447 let labels: Vec<GitHubLabel> = self.github_get_paginated(&url).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 match self
455 .guts_client
456 .create_label(
457 target_owner,
458 target_name,
459 &label.name,
460 &label.color,
461 label.description.as_deref(),
462 )
463 .await
464 {
465 Ok(()) => {
466 count += 1;
467 self.progress.increment(Some(&label.name));
468 }
469 Err(e) => {
470 debug!("Failed to create label {}: {e}", label.name);
471 }
472 }
473 }
474
475 Ok(count)
476 }
477
478 async fn migrate_issues(
479 &self,
480 owner: &str,
481 repo: &str,
482 target_owner: &str,
483 target_name: &str,
484 options: &MigrationOptions,
485 ) -> Result<usize> {
486 let state = if options.include_closed {
487 "all"
488 } else {
489 "open"
490 };
491 let url = format!("https://api.github.com/repos/{owner}/{repo}/issues?state={state}");
492 let issues: Vec<GitHubIssue> = self.github_get_paginated(&url).await?;
493
494 let issues: Vec<_> = issues
496 .into_iter()
497 .filter(|i| {
498 !i.body
499 .as_deref()
500 .map(|b| b.contains("<!-- PR -->"))
501 .unwrap_or(false)
502 })
503 .collect();
504
505 self.progress
506 .set_phase(MigrationPhase::MigratingIssues, issues.len() as u64);
507
508 let mut count = 0;
509 for issue in &issues {
510 let body =
511 self.rewrite_content(issue.body.as_deref().unwrap_or(""), owner, repo, options);
512
513 let body_with_note = format!(
515 "{body}\n\n---\n*Migrated from GitHub issue #{} by @{}*",
516 issue.number, issue.user.login
517 );
518
519 let labels: Vec<String> = issue.labels.iter().map(|l| l.name.clone()).collect();
520
521 match self
522 .guts_client
523 .create_issue(
524 target_owner,
525 target_name,
526 &CreateIssueRequest {
527 title: issue.title.clone(),
528 body: Some(body_with_note),
529 labels,
530 assignees: vec![],
531 },
532 )
533 .await
534 {
535 Ok(guts_issue) => {
536 if let Err(e) = self
538 .migrate_issue_comments(
539 owner,
540 repo,
541 issue.number,
542 target_owner,
543 target_name,
544 guts_issue.number,
545 options,
546 )
547 .await
548 {
549 debug!(
550 "Failed to migrate comments for issue #{}: {e}",
551 issue.number
552 );
553 }
554
555 if issue.state == "closed" {
557 let _ = self
558 .guts_client
559 .close_issue(target_owner, target_name, guts_issue.number)
560 .await;
561 }
562
563 count += 1;
564 self.progress
565 .increment(Some(&format!("Issue #{}", issue.number)));
566 }
567 Err(e) => {
568 debug!("Failed to create issue #{}: {e}", issue.number);
569 }
570 }
571 }
572
573 Ok(count)
574 }
575
576 #[allow(clippy::too_many_arguments)]
577 async fn migrate_issue_comments(
578 &self,
579 owner: &str,
580 repo: &str,
581 issue_number: u64,
582 target_owner: &str,
583 target_name: &str,
584 guts_issue_number: u64,
585 options: &MigrationOptions,
586 ) -> Result<()> {
587 let url =
588 format!("https://api.github.com/repos/{owner}/{repo}/issues/{issue_number}/comments");
589 let comments: Vec<GitHubComment> = self.github_get_paginated(&url).await?;
590
591 for comment in comments {
592 let body = self.rewrite_content(&comment.body, owner, repo, options);
593 let body_with_note = format!(
594 "{body}\n\n---\n*Comment by @{} migrated from GitHub*",
595 comment.user.login
596 );
597
598 let _ = self
599 .guts_client
600 .create_issue_comment(
601 target_owner,
602 target_name,
603 guts_issue_number,
604 &body_with_note,
605 )
606 .await;
607 }
608
609 Ok(())
610 }
611
612 async fn migrate_pull_requests(
613 &self,
614 owner: &str,
615 repo: &str,
616 target_owner: &str,
617 target_name: &str,
618 options: &MigrationOptions,
619 ) -> Result<usize> {
620 let state = if options.include_closed {
621 "all"
622 } else {
623 "open"
624 };
625 let url = format!("https://api.github.com/repos/{owner}/{repo}/pulls?state={state}");
626 let prs: Vec<GitHubPullRequest> = self.github_get_paginated(&url).await?;
627
628 self.progress
629 .set_phase(MigrationPhase::MigratingPullRequests, prs.len() as u64);
630
631 let mut count = 0;
632 for pr in &prs {
633 let body = self.rewrite_content(pr.body.as_deref().unwrap_or(""), owner, repo, options);
634
635 let status = if pr.merged {
637 "merged"
638 } else if pr.state == "closed" {
639 "closed"
640 } else {
641 "open"
642 };
643
644 let body_with_note = format!(
645 "{body}\n\n---\n*Migrated from GitHub PR #{} ({}) by @{}*",
646 pr.number, status, pr.user.login
647 );
648
649 match self
650 .guts_client
651 .create_pull_request(
652 target_owner,
653 target_name,
654 &CreatePullRequestRequest {
655 title: pr.title.clone(),
656 body: Some(body_with_note),
657 source_branch: pr.head.ref_name.clone(),
658 target_branch: pr.base.ref_name.clone(),
659 },
660 )
661 .await
662 {
663 Ok(_guts_pr) => {
664 count += 1;
665 self.progress.increment(Some(&format!("PR #{}", pr.number)));
666 }
667 Err(e) => {
668 debug!("Failed to create PR #{}: {e}", pr.number);
669 }
670 }
671 }
672
673 Ok(count)
674 }
675
676 async fn migrate_releases(
677 &self,
678 owner: &str,
679 repo: &str,
680 target_owner: &str,
681 target_name: &str,
682 ) -> Result<(usize, usize)> {
683 let url = format!("https://api.github.com/repos/{owner}/{repo}/releases");
684 let releases: Vec<GitHubRelease> = self.github_get_paginated(&url).await?;
685
686 self.progress
687 .set_phase(MigrationPhase::MigratingReleases, releases.len() as u64);
688
689 let mut release_count = 0;
690 let mut asset_count = 0;
691
692 for release in &releases {
693 match self
694 .guts_client
695 .create_release(
696 target_owner,
697 target_name,
698 &CreateReleaseRequest {
699 tag_name: release.tag_name.clone(),
700 name: release
701 .name
702 .clone()
703 .unwrap_or_else(|| release.tag_name.clone()),
704 body: release.body.clone(),
705 prerelease: Some(release.prerelease),
706 draft: Some(release.draft),
707 },
708 )
709 .await
710 {
711 Ok(guts_release) => {
712 for asset in &release.assets {
714 if let Ok(data) = self.download_asset(&asset.browser_download_url).await {
715 match self
716 .guts_client
717 .upload_release_asset(
718 target_owner,
719 target_name,
720 &guts_release.id,
721 &asset.name,
722 &asset.content_type,
723 data,
724 )
725 .await
726 {
727 Ok(()) => asset_count += 1,
728 Err(e) => debug!("Failed to upload asset {}: {e}", asset.name),
729 }
730 }
731 }
732
733 release_count += 1;
734 self.progress.increment(Some(&release.tag_name));
735 }
736 Err(e) => {
737 debug!("Failed to create release {}: {e}", release.tag_name);
738 }
739 }
740 }
741
742 Ok((release_count, asset_count))
743 }
744
745 async fn download_asset(&self, url: &str) -> Result<Vec<u8>> {
746 let response = self
747 .github_client
748 .get(url)
749 .header("Authorization", format!("Bearer {}", self.github_token))
750 .header("Accept", "application/octet-stream")
751 .send()
752 .await
753 .map_err(|e| MigrationError::NetworkError(e.to_string()))?;
754
755 if !response.status().is_success() {
756 return Err(MigrationError::ApiError(format!(
757 "Failed to download asset: {}",
758 response.status()
759 )));
760 }
761
762 response
763 .bytes()
764 .await
765 .map(|b| b.to_vec())
766 .map_err(|e| MigrationError::NetworkError(e.to_string()))
767 }
768
769 async fn migrate_wiki(
770 &self,
771 owner: &str,
772 repo: &str,
773 _target_owner: &str,
774 _target_name: &str,
775 ) -> Result<bool> {
776 let wiki_url = format!("https://github.com/{owner}/{repo}.wiki.git");
778
779 let output = Command::new("git")
781 .args(["ls-remote", &wiki_url])
782 .output()?;
783
784 if !output.status.success() {
785 return Ok(false);
786 }
787
788 Ok(false)
790 }
791
792 fn rewrite_content(
793 &self,
794 content: &str,
795 owner: &str,
796 repo: &str,
797 options: &MigrationOptions,
798 ) -> String {
799 if !options.rewrite_links {
800 return content.to_string();
801 }
802
803 let mut result = content.to_string();
804
805 let github_url = format!("https://github.com/{owner}/{repo}");
807 let guts_url = format!("{}/{owner}/{repo}", self.config.guts_url);
808 result = result.replace(&github_url, &guts_url);
809
810 for (github_user, guts_user) in &options.user_mapping {
812 result = result.replace(&format!("@{github_user}"), &format!("@{guts_user}"));
813 }
814
815 result
816 }
817}
818
819#[cfg(test)]
820mod tests {
821 use super::*;
822
823 #[test]
824 fn test_parse_repo() {
825 let config = MigrationConfig::new("owner/repo", "http://localhost:8080");
826 let migrator = GitHubMigrator::new("token", config).unwrap();
827
828 let (owner, repo) = migrator.parse_repo().unwrap();
829 assert_eq!(owner, "owner");
830 assert_eq!(repo, "repo");
831 }
832
833 #[test]
834 fn test_rewrite_content() {
835 let config = MigrationConfig::new("old-owner/old-repo", "https://guts.network");
836 let migrator = GitHubMigrator::new("token", config).unwrap();
837
838 let options = MigrationOptions::default().with_user_mapping("github-user", "guts-user");
839
840 let content = "Check https://github.com/old-owner/old-repo/issues/1 by @github-user";
841 let rewritten = migrator.rewrite_content(content, "old-owner", "old-repo", &options);
842
843 assert!(rewritten.contains("https://guts.network/old-owner/old-repo"));
844 assert!(rewritten.contains("@guts-user"));
845 }
846}