diff --git a/modules/migration/downloader.go b/modules/migration/downloader.go index 669222dea2ad9..3b0460e7070cd 100644 --- a/modules/migration/downloader.go +++ b/modules/migration/downloader.go @@ -24,6 +24,9 @@ type Downloader interface { GetPullRequests(ctx context.Context, page, perPage int) ([]*PullRequest, bool, error) GetReviews(ctx context.Context, reviewable Reviewable) ([]*Review, error) FormatCloneURL(opts MigrateOptions, remoteAddr string) (string, error) + // GetOrgRepositories returns all repositories in an organization with pagination + // The bool return indicates if there are more results + GetOrgRepositories(ctx context.Context, orgName string, page, perPage int) ([]*Repository, bool, error) } // DownloaderFactory defines an interface to match a downloader implementation and create a downloader diff --git a/modules/migration/null_downloader.go b/modules/migration/null_downloader.go index e488f6914f2be..ccc818470af9f 100644 --- a/modules/migration/null_downloader.go +++ b/modules/migration/null_downloader.go @@ -83,3 +83,8 @@ func (n NullDownloader) FormatCloneURL(opts MigrateOptions, remoteAddr string) ( func (n NullDownloader) SupportGetRepoComments() bool { return false } + +// GetOrgRepositories returns all repositories in an organization +func (n NullDownloader) GetOrgRepositories(_ context.Context, orgName string, page, perPage int) ([]*Repository, bool, error) { + return nil, false, ErrNotSupported{Entity: "OrgRepositories"} +} diff --git a/modules/structs/repo.go b/modules/structs/repo.go index 3507cc410a113..349795a426b3c 100644 --- a/modules/structs/repo.go +++ b/modules/structs/repo.go @@ -401,6 +401,49 @@ type MigrateRepoOptions struct { AWSSecretAccessKey string `json:"aws_secret_access_key"` } +// MigrateOrgOptions options for migrating an organization's repositories +// this is used to interact with api v1 +type MigrateOrgOptions struct { + // required: true + CloneAddr string `json:"clone_addr" binding:"Required"` + // required: true + TargetOrgName string `json:"target_org_name" binding:"Required"` + // required: true + SourceOrgName string `json:"source_org_name" binding:"Required"` + + // enum: 0=git,1=plain,2=github,3=gitea,4=gitlab,5=gogs,6=onedev,7=gitbucket,8=codebase,9=codecommit + Service GitServiceType `json:"service"` + AuthUsername string `json:"auth_username"` + AuthPassword string `json:"auth_password"` + AuthToken string `json:"auth_token"` + + Mirror bool `json:"mirror"` + LFS bool `json:"lfs"` + LFSEndpoint string `json:"lfs_endpoint"` + Private bool `json:"private"` + Wiki bool `json:"wiki"` + Milestones bool `json:"milestones"` + Labels bool `json:"labels"` + Issues bool `json:"issues"` + PullRequests bool `json:"pull_requests"` + Releases bool `json:"releases"` + ReleaseAssets bool `json:"release_assets"` + MirrorInterval string `json:"mirror_interval"` +} + +// OrgMigrationResult represents the result of an organization migration +type OrgMigrationResult struct { + TotalRepos int `json:"total_repos"` + MigratedRepos []string `json:"migrated_repos"` + FailedRepos []OrgMigrationFailure `json:"failed_repos"` +} + +// OrgMigrationFailure represents a failed repository migration +type OrgMigrationFailure struct { + RepoName string `json:"repo_name"` + Error string `json:"error"` +} + // TokenAuth represents whether a service type supports token-based auth func (gt GitServiceType) TokenAuth() bool { switch gt { @@ -423,6 +466,18 @@ var SupportedFullGitService = []GitServiceType{ CodeCommitService, } +// SupportedOrgMigrationGitService represents git services that support organization migration. +// CodeCommit is excluded because GetOrgRepositories is not supported. +var SupportedOrgMigrationGitService = []GitServiceType{ + GithubService, + GitlabService, + GiteaService, + GogsService, + OneDevService, + GitBucketService, + CodebaseService, +} + // RepoTransfer represents a pending repo transfer type RepoTransfer struct { Doer *User `json:"doer"` diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index 5a5148a146ebe..a26fd8ae013df 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -55,6 +55,7 @@ "issue_milestone": "Milestone", "new_repo": "New Repository", "new_migrate": "New Migration", + "new_org_migrate": "Migrate Organization", "new_mirror": "New Mirror", "new_fork": "New Repository Fork", "new_org": "New Organization", @@ -1101,6 +1102,7 @@ "repo.migrate_items_pullrequests": "Pull Requests", "repo.migrate_items_merge_requests": "Merge Requests", "repo.migrate_items_releases": "Releases", + "repo.migrate_items_release_assets": "Release Assets", "repo.migrate_repo": "Migrate Repository", "repo.migrate.clone_address": "Migrate / Clone From URL", "repo.migrate.clone_address_desc": "The HTTP(S) or Git 'clone' URL of an existing repository", @@ -2711,6 +2713,30 @@ "org.code": "Code", "org.lower_members": "members", "org.lower_repositories": "repositories", + "org.migrate.title": "Migrate Organization", + "org.migrate.clone_address_desc": "The URL of the Git service where the organization is hosted", + "org.migrate.service": "Git Service", + "org.migrate.source_org_name": "Source Organization Name", + "org.migrate.source_org_name_desc": "The name of the organization on the source platform", + "org.migrate.target_org_name": "Target Organization", + "org.migrate.select_target_org": "Select a target organization", + "org.migrate.submit": "Migrate Organization", + "org.migrate.success_title": "Organization Migration Complete", + "org.migrate.success": "Successfully migrated organization", + "org.migrate.result": "Migration Results", + "org.migrate.total_repos": "Total Repositories", + "org.migrate.migrated_repos": "Migrated Repositories", + "org.migrate.failed_repos": "Failed Repositories", + "org.migrate.migrated_repos_list": "Successfully migrated:", + "org.migrate.failed_repos_list": "Failed to migrate:", + "org.migrate.view_org": "View Organization", + "org.migrate.migrate_another": "Migrate Another Organization", + "org.migrate.target_org_not_exist": "Target organization does not exist", + "org.migrate.permission_denied": "You must be an owner of the target organization", + "org.migrate.service_not_supported": "This Git service does not support organization migration", + "org.migrate.failed": "Organization migration failed: %v", + "org.migrate.mirror_helper": "Set up all migrated repositories as mirrors", + "org.migrate.private_helper": "Make all migrated repositories private", "org.create_new_team": "New Team", "org.create_team": "Create Team", "org.org_desc": "Description", diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 560894b798e56..281b4c3967a9e 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -1604,6 +1604,7 @@ func Routes() *web.Router { }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser, auth_model.AccessTokenScopeCategoryOrganization), context.UserAssignmentAPI(), checkTokenPublicOnly()) m.Post("/orgs", tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization), reqToken(), bind(api.CreateOrgOption{}), org.Create) m.Get("/orgs", org.GetAll, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization)) + m.Post("/orgs/migrate", tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization, auth_model.AccessTokenScopeCategoryRepository), reqToken(), bind(api.MigrateOrgOptions{}), org.MigrateOrg) m.Group("/orgs/{org}", func() { m.Combo("").Get(org.Get). Patch(reqToken(), reqOrgOwnership(), bind(api.EditOrgOption{}), org.Edit). diff --git a/routers/api/v1/org/migrate.go b/routers/api/v1/org/migrate.go new file mode 100644 index 0000000000000..f77490878457c --- /dev/null +++ b/routers/api/v1/org/migrate.go @@ -0,0 +1,151 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package org + +import ( + gocontext "context" + "errors" + "fmt" + "net/http" + + "code.gitea.io/gitea/models/organization" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/graceful" + "code.gitea.io/gitea/modules/log" + base "code.gitea.io/gitea/modules/migration" + "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/migrations" +) + +// MigrateOrg migrates all repositories from an organization on another platform +func MigrateOrg(ctx *context.APIContext) { + // swagger:operation POST /orgs/migrate organization orgMigrate + // --- + // summary: Migrate an organization's repositories + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/MigrateOrgOptions" + // responses: + // "201": + // "$ref": "#/responses/OrgMigrationResult" + // "403": + // "$ref": "#/responses/forbidden" + // "422": + // "$ref": "#/responses/validationError" + + form := web.GetForm(ctx).(*api.MigrateOrgOptions) + + // Check if migrations are enabled + if setting.Repository.DisableMigrations { + ctx.APIError(http.StatusForbidden, errors.New("the site administrator has disabled migrations")) + return + } + + // Check if mirrors are allowed + if form.Mirror && setting.Mirror.DisableNewPull { + ctx.APIError(http.StatusBadRequest, errors.New("the site administrator has disabled creation of new mirrors")) + return + } + + // Get target organization + targetOrg, err := organization.GetOrgByName(ctx, form.TargetOrgName) + if err != nil { + if organization.IsErrOrgNotExist(err) { + ctx.APIError(http.StatusUnprocessableEntity, fmt.Errorf("target organization '%s' does not exist", form.TargetOrgName)) + } else { + ctx.APIErrorInternal(err) + } + return + } + + // Check permissions - user must be an owner of the target organization + if !ctx.Doer.IsAdmin { + isOwner, err := targetOrg.IsOwnedBy(ctx, ctx.Doer.ID) + if err != nil { + ctx.APIErrorInternal(err) + return + } + if !isOwner { + ctx.APIError(http.StatusForbidden, "Only organization owners can migrate repositories") + return + } + } + + // Validate the source URL before attempting migration + if err := migrations.IsMigrateURLAllowed(form.CloneAddr, ctx.Doer); err != nil { + ctx.APIError(http.StatusUnprocessableEntity, err) + return + } + + opts := migrations.OrgMigrateOptions{ + CloneAddr: form.CloneAddr, + AuthUsername: form.AuthUsername, + AuthPassword: form.AuthPassword, + AuthToken: form.AuthToken, + TargetOrgName: form.TargetOrgName, + SourceOrgName: form.SourceOrgName, + GitServiceType: form.Service, + Private: form.Private || setting.Repository.ForcePrivate, + Mirror: form.Mirror, + LFS: form.LFS && setting.LFS.StartServer, + LFSEndpoint: form.LFSEndpoint, + Wiki: form.Wiki, + Issues: form.Issues, + Milestones: form.Milestones, + Labels: form.Labels, + Releases: form.Releases, + ReleaseAssets: form.ReleaseAssets, + Comments: form.Issues || form.PullRequests, + PullRequests: form.PullRequests, + MirrorInterval: form.MirrorInterval, + } + + // Perform the migration in background context + doLongTimeMigrate := func(ctx gocontext.Context, doer *user_model.User) (*migrations.OrgMigrationResult, error) { + result, err := migrations.MigrateOrganization(ctx, doer, opts, nil) + if err != nil { + return nil, err + } + return result, nil + } + + // Use hammer context to not cancel if client disconnects + result, err := doLongTimeMigrate(graceful.GetManager().HammerContext(), ctx.Doer) + if err != nil { + if migrations.IsRateLimitError(err) { + ctx.APIError(http.StatusUnprocessableEntity, "Remote visit addressed rate limitation.") + } else if base.IsErrNotSupported(err) { + ctx.APIError(http.StatusUnprocessableEntity, err) + } else { + log.Error("Failed to migrate organization: %v", err) + ctx.APIErrorInternal(err) + } + return + } + + // Convert result to API response + apiResult := &api.OrgMigrationResult{ + TotalRepos: result.TotalRepos, + MigratedRepos: result.MigratedRepos, + FailedRepos: make([]api.OrgMigrationFailure, len(result.FailedRepos)), + } + + for i, f := range result.FailedRepos { + apiResult.FailedRepos[i] = api.OrgMigrationFailure{ + RepoName: f.RepoName, + Error: f.Error.Error(), + } + } + + ctx.JSON(http.StatusCreated, apiResult) +} diff --git a/routers/api/v1/swagger/options.go b/routers/api/v1/swagger/options.go index f66cef61df2d3..bd325dc3731fd 100644 --- a/routers/api/v1/swagger/options.go +++ b/routers/api/v1/swagger/options.go @@ -177,6 +177,8 @@ type swaggerParameterBodies struct { // in:body MigrateRepoOptions api.MigrateRepoOptions + // in:body + MigrateOrgOptions api.MigrateOrgOptions // in:body PullReviewRequestOptions api.PullReviewRequestOptions diff --git a/routers/api/v1/swagger/org.go b/routers/api/v1/swagger/org.go index 0105446b00a33..2383ec40326d6 100644 --- a/routers/api/v1/swagger/org.go +++ b/routers/api/v1/swagger/org.go @@ -41,3 +41,10 @@ type swaggerResponseOrganizationPermissions struct { // in:body Body api.OrganizationPermissions `json:"body"` } + +// OrgMigrationResult +// swagger:response OrgMigrationResult +type swaggerResponseOrgMigrationResult struct { + // in:body + Body api.OrgMigrationResult `json:"body"` +} diff --git a/routers/web/org/migrate.go b/routers/web/org/migrate.go new file mode 100644 index 0000000000000..445d874d80849 --- /dev/null +++ b/routers/web/org/migrate.go @@ -0,0 +1,166 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package org + +import ( + gocontext "context" + "net/http" + + "code.gitea.io/gitea/models/organization" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/graceful" + "code.gitea.io/gitea/modules/log" + base "code.gitea.io/gitea/modules/migration" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/templates" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/forms" + "code.gitea.io/gitea/services/migrations" +) + +const tplOrgMigrate templates.TplName = "org/migrate" + +// MigrateOrg render organization migration page +func MigrateOrg(ctx *context.Context) { + if setting.Repository.DisableMigrations { + ctx.HTTPError(http.StatusForbidden, "MigrateOrg: the site administrator has disabled migrations") + return + } + + ctx.Data["Title"] = ctx.Tr("org.migrate.title") + ctx.Data["LFSActive"] = setting.LFS.StartServer + ctx.Data["IsForcedPrivate"] = setting.Repository.ForcePrivate + ctx.Data["DisableNewPullMirrors"] = setting.Mirror.DisableNewPull + + ctx.Data["Services"] = append([]structs.GitServiceType{structs.PlainGitService}, structs.SupportedOrgMigrationGitService...) + ctx.Data["service"] = structs.GitServiceType(ctx.FormInt("service_type")) + + // Get organizations the user can migrate to + orgs, err := organization.GetOrgsCanCreateRepoByUserID(ctx, ctx.Doer.ID) + if err != nil { + ctx.ServerError("GetOrgsCanCreateRepoByUserID", err) + return + } + ctx.Data["Orgs"] = orgs + + ctx.HTML(http.StatusOK, tplOrgMigrate) +} + +// MigrateOrgPost handles organization migration form submission +func MigrateOrgPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.MigrateOrgForm) + + if setting.Repository.DisableMigrations { + ctx.HTTPError(http.StatusForbidden, "MigrateOrgPost: the site administrator has disabled migrations") + return + } + + if form.Mirror && setting.Mirror.DisableNewPull { + ctx.HTTPError(http.StatusBadRequest, "MigrateOrgPost: the site administrator has disabled creation of new mirrors") + return + } + + ctx.Data["Title"] = ctx.Tr("org.migrate.title") + ctx.Data["LFSActive"] = setting.LFS.StartServer + ctx.Data["IsForcedPrivate"] = setting.Repository.ForcePrivate + ctx.Data["DisableNewPullMirrors"] = setting.Mirror.DisableNewPull + ctx.Data["Services"] = append([]structs.GitServiceType{structs.PlainGitService}, structs.SupportedOrgMigrationGitService...) + ctx.Data["service"] = form.Service + + // Get organizations the user can migrate to + orgs, err := organization.GetOrgsCanCreateRepoByUserID(ctx, ctx.Doer.ID) + if err != nil { + ctx.ServerError("GetOrgsCanCreateRepoByUserID", err) + return + } + ctx.Data["Orgs"] = orgs + + // Validate target organization + targetOrg, err := organization.GetOrgByName(ctx, form.TargetOrgName) + if err != nil { + if organization.IsErrOrgNotExist(err) { + ctx.Data["Err_TargetOrgName"] = true + ctx.RenderWithErrDeprecated(ctx.Tr("org.migrate.target_org_not_exist"), tplOrgMigrate, form) + return + } + ctx.ServerError("GetOrgByName", err) + return + } + + // Check permissions - user must be an owner of the target organization + isOwner, err := targetOrg.IsOwnedBy(ctx, ctx.Doer.ID) + if err != nil { + ctx.ServerError("IsOwnedBy", err) + return + } + if !isOwner && !ctx.Doer.IsAdmin { + ctx.Data["Err_TargetOrgName"] = true + ctx.RenderWithErrDeprecated(ctx.Tr("org.migrate.permission_denied"), tplOrgMigrate, form) + return + } + + if ctx.HasError() { + ctx.HTML(http.StatusOK, tplOrgMigrate) + return + } + + // Validate source URL + remoteAddr := form.CloneAddr + err = migrations.IsMigrateURLAllowed(remoteAddr, ctx.Doer) + if err != nil { + ctx.Data["Err_CloneAddr"] = true + ctx.RenderWithErrDeprecated(ctx.Tr("form.url_error", remoteAddr), tplOrgMigrate, form) + return + } + + opts := migrations.OrgMigrateOptions{ + CloneAddr: form.CloneAddr, + AuthUsername: form.AuthUsername, + AuthPassword: form.AuthPassword, + AuthToken: form.AuthToken, + TargetOrgName: form.TargetOrgName, + SourceOrgName: form.SourceOrgName, + GitServiceType: form.Service, + Private: form.Private || setting.Repository.ForcePrivate, + Mirror: form.Mirror, + LFS: form.LFS && setting.LFS.StartServer, + LFSEndpoint: form.LFSEndpoint, + Wiki: form.Wiki, + Issues: form.Issues, + Milestones: form.Milestones, + Labels: form.Labels, + Releases: form.Releases, + ReleaseAssets: form.ReleaseAssets, + Comments: form.Issues || form.PullRequests, + PullRequests: form.PullRequests, + MirrorInterval: form.MirrorInterval, + } + + // Perform the migration in background context + doLongTimeMigrate := func(ctx gocontext.Context, doer *user_model.User) (*migrations.OrgMigrationResult, error) { + return migrations.MigrateOrganization(ctx, doer, opts, nil) + } + + // Use hammer context to not cancel if client disconnects + result, err := doLongTimeMigrate(graceful.GetManager().HammerContext(), ctx.Doer) + if err != nil { + if migrations.IsRateLimitError(err) { + ctx.RenderWithErrDeprecated(ctx.Tr("form.visit_rate_limit"), tplOrgMigrate, form) + } else if base.IsErrNotSupported(err) { + ctx.Data["Err_CloneAddr"] = true + ctx.RenderWithErrDeprecated(ctx.Tr("org.migrate.service_not_supported"), tplOrgMigrate, form) + } else { + log.Error("Failed to migrate organization: %v", err) + ctx.RenderWithErrDeprecated(ctx.Tr("org.migrate.failed", err.Error()), tplOrgMigrate, form) + } + return + } + + // Show success page with results + ctx.Data["Result"] = result + ctx.Data["TargetOrgName"] = form.TargetOrgName + ctx.HTML(http.StatusOK, templates.TplName("org/migrate_success")) +} diff --git a/routers/web/web.go b/routers/web/web.go index 8da7609994a2a..a6188068298ba 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -918,6 +918,8 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { m.Group("", func() { m.Get("/create", org.Create) m.Post("/create", web.Bind(forms.CreateOrgForm{}), org.CreatePost) + m.Get("/migrate", org.MigrateOrg) + m.Post("/migrate", web.Bind(forms.MigrateOrgForm{}), org.MigrateOrgPost) }) m.Group("/invite/{token}", func() { diff --git a/services/forms/org.go b/services/forms/org.go index 3997e1da8450e..e810b82177f5e 100644 --- a/services/forms/org.go +++ b/services/forms/org.go @@ -77,3 +77,38 @@ func (f *CreateTeamForm) Validate(req *http.Request, errs binding.Errors) bindin ctx := context.GetValidateContext(req) return middleware.Validate(errs, ctx.Data, f, ctx.Locale) } + +// MigrateOrgForm form for migrating an organization's repositories +// this is used to interact with the web UI +type MigrateOrgForm struct { + // Source platform settings + CloneAddr string `json:"clone_addr" binding:"Required"` + Service structs.GitServiceType `json:"service"` + AuthUsername string `json:"auth_username"` + AuthPassword string `json:"auth_password"` + AuthToken string `json:"auth_token"` + SourceOrgName string `json:"source_org_name" binding:"Required"` + + // Target settings + TargetOrgName string `json:"target_org_name" binding:"Required"` + + // Migration options + Mirror bool `json:"mirror"` + LFS bool `json:"lfs"` + LFSEndpoint string `json:"lfs_endpoint"` + Private bool `json:"private"` + Wiki bool `json:"wiki"` + Milestones bool `json:"milestones"` + Labels bool `json:"labels"` + Issues bool `json:"issues"` + PullRequests bool `json:"pull_requests"` + Releases bool `json:"releases"` + ReleaseAssets bool `json:"release_assets"` + MirrorInterval string `json:"mirror_interval"` +} + +// Validate validates the fields +func (f *MigrateOrgForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { + ctx := context.GetValidateContext(req) + return middleware.Validate(errs, ctx.Data, f, ctx.Locale) +} diff --git a/services/migrations/codebase.go b/services/migrations/codebase.go index 240c7bcdc9ebe..dbe28feddfee5 100644 --- a/services/migrations/codebase.go +++ b/services/migrations/codebase.go @@ -651,3 +651,52 @@ func (d *CodebaseDownloader) getHeadCommit(ctx context.Context, ref string) stri } return commitRef } + +// GetOrgRepositories returns all repositories in an organization +func (d *CodebaseDownloader) GetOrgRepositories(ctx context.Context, orgName string, page, perPage int) ([]*base.Repository, bool, error) { + var rawProjects struct { + XMLName xml.Name `xml:"projects"` + Type string `xml:"type,attr"` + Project []struct { + Name string `xml:"name"` + Permalink string `xml:"permalink"` + Description string `xml:"description"` + CloneURL string `xml:"clone-url"` + } `xml:"project"` + } + + offset := (page - 1) * perPage + // Codebase API doesn't have direct pagination for project listing + // We'll query for all projects and then paginate in code + err := d.callAPI( + ctx, + "/"+orgName, + nil, + &rawProjects, + ) + if err != nil { + return nil, false, err + } + + result := make([]*base.Repository, 0, len(rawProjects.Project)) + for _, project := range rawProjects.Project { + result = append(result, &base.Repository{ + Name: project.Name, + Owner: orgName, + Description: project.Description, + CloneURL: project.CloneURL, + OriginalURL: d.projectURL.String() + "/" + project.Permalink, + }) + } + + start := offset + end := offset + perPage + if start >= len(result) { + return []*base.Repository{}, true, nil + } + if end > len(result) { + end = len(result) + } + + return result[start:end], end >= len(result), nil +} diff --git a/services/migrations/codecommit.go b/services/migrations/codecommit.go index 188280273f7bf..0db272ec74b1d 100644 --- a/services/migrations/codecommit.go +++ b/services/migrations/codecommit.go @@ -264,3 +264,8 @@ func (c *CodeCommitDownloader) getUsernameFromARN(arn string) string { } return "" } + +// GetOrgRepositories returns all repositories in an organization +func (c *CodeCommitDownloader) GetOrgRepositories(_ context.Context, _ string, _, _ int) ([]*base.Repository, bool, error) { + return nil, false, base.ErrNotSupported{Entity: "OrgRepositories"} +} diff --git a/services/migrations/gitea_downloader.go b/services/migrations/gitea_downloader.go index 5609e326301e4..59cce34cec176 100644 --- a/services/migrations/gitea_downloader.go +++ b/services/migrations/gitea_downloader.go @@ -712,3 +712,37 @@ func (g *GiteaDownloader) GetReviews(ctx context.Context, reviewable base.Review } return allReviews, nil } + +// GetOrgRepositories returns all repositories in an organization +func (g *GiteaDownloader) GetOrgRepositories(ctx context.Context, orgName string, page, perPage int) ([]*base.Repository, bool, error) { + if perPage > g.maxPerPage { + perPage = g.maxPerPage + } + + opt := gitea_sdk.ListOrgReposOptions{ + ListOptions: gitea_sdk.ListOptions{ + Page: page, + PageSize: perPage, + }, + } + + repos, resp, err := g.client.ListOrgRepos(orgName, opt) + if err != nil { + return nil, false, err + } + + result := make([]*base.Repository, 0, len(repos)) + for _, repo := range repos { + result = append(result, &base.Repository{ + Name: repo.Name, + Owner: repo.Owner.UserName, + IsPrivate: repo.Private, + Description: repo.Description, + OriginalURL: repo.HTMLURL, + CloneURL: repo.CloneURL, + DefaultBranch: repo.DefaultBranch, + }) + } + + return result, resp.NextPage == 0, nil +} diff --git a/services/migrations/github.go b/services/migrations/github.go index ce631dcd42769..506ebc6f84c7c 100644 --- a/services/migrations/github.go +++ b/services/migrations/github.go @@ -900,3 +900,40 @@ func (g *GithubDownloaderV3) FormatCloneURL(opts MigrateOptions, remoteAddr stri } return u.String(), nil } + +// GetOrgRepositories returns all repositories in an organization +func (g *GithubDownloaderV3) GetOrgRepositories(ctx context.Context, orgName string, page, perPage int) ([]*base.Repository, bool, error) { + if perPage > g.maxPerPage { + perPage = g.maxPerPage + } + + g.waitAndPickClient(ctx) + opt := &github.RepositoryListByOrgOptions{ + ListOptions: github.ListOptions{ + Page: page, + PerPage: perPage, + }, + Type: "all", + } + + repos, resp, err := g.getClient().Repositories.ListByOrg(ctx, orgName, opt) + if err != nil { + return nil, false, err + } + g.setRate(&resp.Rate) + + result := make([]*base.Repository, 0, len(repos)) + for _, repo := range repos { + result = append(result, &base.Repository{ + Name: repo.GetName(), + Owner: repo.Owner.GetLogin(), + IsPrivate: repo.GetPrivate(), + Description: repo.GetDescription(), + OriginalURL: repo.GetHTMLURL(), + CloneURL: repo.GetCloneURL(), + DefaultBranch: repo.GetDefaultBranch(), + }) + } + + return result, resp.NextPage == 0, nil +} diff --git a/services/migrations/gitlab.go b/services/migrations/gitlab.go index cbf974af2ce47..ffe25adc3dbdc 100644 --- a/services/migrations/gitlab.go +++ b/services/migrations/gitlab.go @@ -773,3 +773,39 @@ func (g *GitlabDownloader) awardsToReactions(awards []*gitlab.AwardEmoji) []*bas } return result } + +// GetOrgRepositories returns all repositories in a GitLab group +// In GitLab, organizations are called "groups" +func (g *GitlabDownloader) GetOrgRepositories(ctx context.Context, groupName string, page, perPage int) ([]*base.Repository, bool, error) { + if perPage > g.maxPerPage { + perPage = g.maxPerPage + } + + opt := &gitlab.ListGroupProjectsOptions{ + ListOptions: gitlab.ListOptions{ + Page: page, + PerPage: perPage, + }, + IncludeSubGroups: new(bool), + } + + projects, resp, err := g.client.Groups.ListGroupProjects(groupName, opt, gitlab.WithContext(ctx)) + if err != nil { + return nil, false, err + } + + result := make([]*base.Repository, 0, len(projects)) + for _, project := range projects { + result = append(result, &base.Repository{ + Name: project.Name, + Owner: project.Namespace.Path, + IsPrivate: project.Visibility != gitlab.PublicVisibility, + Description: project.Description, + OriginalURL: project.WebURL, + CloneURL: project.HTTPURLToRepo, + DefaultBranch: project.DefaultBranch, + }) + } + + return result, resp.NextPage == 0, nil +} diff --git a/services/migrations/gogs.go b/services/migrations/gogs.go index a4f84dbf72c60..ea597916aa219 100644 --- a/services/migrations/gogs.go +++ b/services/migrations/gogs.go @@ -310,3 +310,29 @@ func convertGogsLabel(label *gogs.Label) *base.Label { Color: label.Color, } } + +// GetOrgRepositories returns all repositories in an organization. +// Note: the Gogs SDK does not support pagination for listing org repos, so all +// repositories are fetched in a single request regardless of the page/perPage parameters. +// This may cause memory pressure for organizations with many repositories. +func (g *GogsDownloader) GetOrgRepositories(ctx context.Context, orgName string, _, _ int) ([]*base.Repository, bool, error) { + repos, err := g.client(ctx).ListOrgRepos(orgName) + if err != nil { + return nil, false, err + } + + result := make([]*base.Repository, 0, len(repos)) + for _, repo := range repos { + result = append(result, &base.Repository{ + Owner: orgName, + Name: repo.Name, + IsPrivate: repo.Private, + Description: repo.Description, + CloneURL: repo.CloneURL, + OriginalURL: repo.HTMLURL, + DefaultBranch: repo.DefaultBranch, + }) + } + + return result, true, nil +} diff --git a/services/migrations/migrate.go b/services/migrations/migrate.go index 99f8dba92f470..2400d3daa8d74 100644 --- a/services/migrations/migrate.go +++ b/services/migrations/migrate.go @@ -22,6 +22,7 @@ import ( "code.gitea.io/gitea/modules/log" base "code.gitea.io/gitea/modules/migration" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/util" ) @@ -510,6 +511,149 @@ func migrateRepository(ctx context.Context, doer *user_model.User, downloader ba return uploader.Finish(ctx) } +// OrgMigrateOptions defines the options for migrating an organization +type OrgMigrateOptions struct { + // Source URL (e.g., https://github.com/myorg) + CloneAddr string `json:"clone_addr" binding:"Required"` + // Auth credentials + AuthUsername string `json:"auth_username"` + AuthPassword string `json:"-"` + AuthToken string `json:"-"` + // Target organization name (will be created if doesn't exist) + TargetOrgName string `json:"target_org_name" binding:"Required"` + // Original organization name to migrate from + SourceOrgName string `json:"source_org_name" binding:"Required"` + // Git service type + GitServiceType structs.GitServiceType + // Migration options + Private bool `json:"private"` + Mirror bool `json:"mirror"` + LFS bool `json:"lfs"` + LFSEndpoint string `json:"lfs_endpoint"` + Wiki bool + Issues bool + Milestones bool + Labels bool + Releases bool + Comments bool + PullRequests bool + ReleaseAssets bool + MirrorInterval string `json:"mirror_interval"` +} + +// OrgMigrationResult contains the result of an organization migration +type OrgMigrationResult struct { + TotalRepos int + MigratedRepos []string + FailedRepos []OrgMigrationFailure +} + +// OrgMigrationFailure contains details about a failed repository migration +type OrgMigrationFailure struct { + RepoName string + Error error +} + +// MigrateOrganization migrates all repositories from an organization +func MigrateOrganization(ctx context.Context, doer *user_model.User, opts OrgMigrateOptions, messenger base.Messenger) (*OrgMigrationResult, error) { + if setting.Repository.DisableMigrations { + return nil, errors.New("the site administrator has disabled migrations") + } + + err := IsMigrateURLAllowed(opts.CloneAddr, doer) + if err != nil { + return nil, err + } + + // Create a downloader to list repositories + // The factory expects a path like /owner/repo, but GetOrgRepositories only uses the orgName parameter. + // We add a placeholder repo name to prevent the factory from panicking when parsing the URL. + downloaderOpts := base.MigrateOptions{ + CloneAddr: opts.CloneAddr + "/" + opts.SourceOrgName + "/placeholder", + AuthUsername: opts.AuthUsername, + AuthPassword: opts.AuthPassword, + AuthToken: opts.AuthToken, + GitServiceType: opts.GitServiceType, + } + + downloader, err := newDownloader(ctx, opts.SourceOrgName, downloaderOpts) + if err != nil { + return nil, fmt.Errorf("failed to create downloader: %w", err) + } + + result := &OrgMigrationResult{ + MigratedRepos: make([]string, 0), + FailedRepos: make([]OrgMigrationFailure, 0), + } + + // List all repositories in the organization + perPage := 100 + for page := 1; ; page++ { + repos, isEnd, err := downloader.GetOrgRepositories(ctx, opts.SourceOrgName, page, perPage) + if err != nil { + if base.IsErrNotSupported(err) { + return nil, errors.New("organization migration is not supported for this git service type") + } + return nil, fmt.Errorf("failed to list repositories: %w", err) + } + + for _, repo := range repos { + result.TotalRepos++ + + // Use the clone URL from the provider if available, otherwise construct it + cloneAddr := repo.CloneURL + if cloneAddr == "" { + cloneAddr = opts.CloneAddr + "/" + repo.Owner + "/" + repo.Name + } + repoOpts := base.MigrateOptions{ + CloneAddr: cloneAddr, + AuthUsername: opts.AuthUsername, + AuthPassword: opts.AuthPassword, + AuthToken: opts.AuthToken, + RepoName: repo.Name, + Description: repo.Description, + Private: opts.Private || repo.IsPrivate, + Mirror: opts.Mirror, + LFS: opts.LFS, + LFSEndpoint: opts.LFSEndpoint, + Wiki: opts.Wiki, + Issues: opts.Issues, + Milestones: opts.Milestones, + Labels: opts.Labels, + Releases: opts.Releases, + Comments: opts.Comments, + PullRequests: opts.PullRequests, + ReleaseAssets: opts.ReleaseAssets, + MirrorInterval: opts.MirrorInterval, + OriginalURL: repo.OriginalURL, + GitServiceType: opts.GitServiceType, + } + + if messenger != nil { + messenger("repo.migrate.migrating_repo", repo.Name) + } + + _, err := MigrateRepository(ctx, doer, opts.TargetOrgName, repoOpts, messenger) + if err != nil { + log.Error("Failed to migrate repository %s: %v", repo.Name, err) + result.FailedRepos = append(result.FailedRepos, OrgMigrationFailure{ + RepoName: repo.Name, + Error: err, + }) + continue + } + + result.MigratedRepos = append(result.MigratedRepos, repo.Name) + } + + if isEnd { + break + } + } + + return result, nil +} + // Init migrations service func Init() error { // TODO: maybe we can deprecate these legacy ALLOWED_DOMAINS/ALLOW_LOCALNETWORKS/BLOCKED_DOMAINS, use ALLOWED_HOST_LIST/BLOCKED_HOST_LIST instead diff --git a/services/migrations/onedev.go b/services/migrations/onedev.go index a30e36c8b8318..91aef038c0f39 100644 --- a/services/migrations/onedev.go +++ b/services/migrations/onedev.go @@ -687,3 +687,45 @@ func (d *OneDevDownloader) tryGetUser(ctx context.Context, userID int64) *onedev return user } + +// GetOrgRepositories returns all repositories in an organization +func (d *OneDevDownloader) GetOrgRepositories(ctx context.Context, orgName string, page, perPage int) ([]*base.Repository, bool, error) { + rawProjects := make([]struct { + ID int64 `json:"id"` + Name string `json:"name"` + Path string `json:"path"` + Description string `json:"description"` + }, 0, perPage) + + err := d.callAPI( + ctx, + "/~api/projects", + map[string]string{ + "query": `"Path" starts with "` + orgName + `/"`, + "offset": strconv.Itoa((page - 1) * perPage), + "count": strconv.Itoa(perPage), + }, + &rawProjects, + ) + if err != nil { + return nil, false, err + } + + result := make([]*base.Repository, 0, len(rawProjects)) + for _, project := range rawProjects { + cloneURL, err := d.baseURL.Parse(project.Path) + if err != nil { + return nil, false, err + } + + result = append(result, &base.Repository{ + Name: project.Name, + Owner: orgName, + Description: project.Description, + CloneURL: cloneURL.String(), + OriginalURL: cloneURL.String(), + }) + } + + return result, len(result) < perPage, nil +} diff --git a/templates/base/head_navbar.tmpl b/templates/base/head_navbar.tmpl index 447f78565e0c3..da9bac2697310 100644 --- a/templates/base/head_navbar.tmpl +++ b/templates/base/head_navbar.tmpl @@ -73,16 +73,22 @@ {{svg "octicon-plus"}} {{ctx.Locale.Tr "new_repo"}} - {{if not .DisableMigrations}} - - {{svg "octicon-repo-push"}} {{ctx.Locale.Tr "new_migrate"}} - - {{end}} - {{if .SignedUser.CanCreateOrganization}} - - {{svg "octicon-organization"}} {{ctx.Locale.Tr "new_org"}} + {{if not .DisableMigrations}} + + {{svg "octicon-repo-push"}} {{ctx.Locale.Tr "new_migrate"}} - {{end}} + {{end}} + {{if .SignedUser.CanCreateOrganization}} +
+ + {{svg "octicon-organization"}} {{ctx.Locale.Tr "new_org"}} + + {{if not .DisableMigrations}} + + {{svg "octicon-repo-push"}} {{ctx.Locale.Tr "new_org_migrate"}} + + {{end}} + {{end}} diff --git a/templates/org/migrate.tmpl b/templates/org/migrate.tmpl new file mode 100644 index 0000000000000..dca90fa3a2898 --- /dev/null +++ b/templates/org/migrate.tmpl @@ -0,0 +1,160 @@ +{{template "base/head" .}} +