Skip to main content

guts_migrate/
github.rs

1//! GitHub 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/// GitHub API response types
17#[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
95/// Migrator for GitHub repositories.
96pub 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    /// Create a new GitHub migrator.
106    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    /// Set a progress callback.
125    pub fn with_progress(mut self, progress: MigrationProgress) -> Self {
126        self.progress = progress;
127        self
128    }
129
130    /// Run the migration.
131    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        // Parse owner/repo
138        let (owner, repo_name) = self.parse_repo()?;
139
140        // Step 1: Fetch repository info from GitHub
141        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        // Step 2: Create repository on Guts
146        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        // Step 3: Mirror Git repository
168        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        // Step 4: Migrate labels
187        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        // Step 5: Migrate issues
205        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        // Step 6: Migrate pull requests
222        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        // Step 7: Migrate releases
239        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        // Step 8: Migrate wiki (if available)
257        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            // Check for rate limiting
309            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        // Clone with all branches and tags (mirror)
381        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        // 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        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        // Filter out pull requests (GitHub API returns PRs in issues endpoint)
495        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            // Add migration note
514            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                    // Migrate comments
537                    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                    // Close if closed on GitHub
556                    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            // Add migration note with status
636            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                    // Upload assets
713                    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        // Wiki migration is optional - it's a separate git repository
777        let wiki_url = format!("https://github.com/{owner}/{repo}.wiki.git");
778
779        // Just check if wiki exists
780        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        // TODO: Implement full wiki migration when Guts supports wiki
789        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        // Rewrite GitHub URLs to Guts URLs
806        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        // Rewrite user mentions if mapping exists
811        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}