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" .}} +
+
+

+ {{ctx.Locale.Tr "org.migrate.title"}} +

+
+ {{template "base/alert" .}} +
+ {{.CsrfTokenHtml}} + +
+ + + {{ctx.Locale.Tr "org.migrate.clone_address_desc"}} +
+ +
+ + +
+ +
+ + + {{ctx.Locale.Tr "org.migrate.source_org_name_desc"}} +
+ +
+ + + {{ctx.Locale.Tr "repo.migrate.github_token_desc"}} +
+
+ + +
+
+ + +
+ + {{if not .DisableNewPullMirrors}} +
+ +
+ + +
+
+ {{end}} + {{if .LFSActive}} +
+ +
+ + +
+ ({{ctx.Locale.Tr "repo.settings.advanced_settings"}}) +
+
+ {{ctx.Locale.Tr "repo.migrate_options_lfs_endpoint.description" "https://github.com/git-lfs/git-lfs/blob/main/docs/api/server-discovery.md#server-discovery"}}{{if .ContextUser.CanImportLocal}} {{ctx.Locale.Tr "repo.migrate_options_lfs_endpoint.description.local"}}{{end}} +
+ + +
+
+ {{end}} + +
+ +
+ + +
+
+
+ {{ctx.Locale.Tr "repo.migrate.migrate_items_options"}} +
+ +
+ + +
+
+ + +
+
+
+ +
+ + +
+
+ + +
+
+ + +
+
+
+ +
+ + +
+
+
+ +
+ +
+ + +
+ +
+ +
+ {{if .IsForcedPrivate}} + + + {{else}} + + + {{end}} +
+
+ +
+ + +
+
+
+
+
+{{template "base/footer" .}} diff --git a/templates/org/migrate_success.tmpl b/templates/org/migrate_success.tmpl new file mode 100644 index 0000000000000..eaabd420995d2 --- /dev/null +++ b/templates/org/migrate_success.tmpl @@ -0,0 +1,58 @@ +{{template "base/head" .}} +
+
+

+ {{ctx.Locale.Tr "org.migrate.success_title"}} +

+
+
+

{{ctx.Locale.Tr "org.migrate.success" .TargetOrgName}}

+
+ +
+

{{ctx.Locale.Tr "org.migrate.result"}}

+
+
+ {{ctx.Locale.Tr "org.migrate.total_repos"}}: {{.Result.TotalRepos}} +
+
+ {{ctx.Locale.Tr "org.migrate.migrated_repos"}}: {{len .Result.MigratedRepos}} +
+ {{if .Result.FailedRepos}} +
+ {{ctx.Locale.Tr "org.migrate.failed_repos"}}: {{len .Result.FailedRepos}} +
    + {{range .Result.FailedRepos}} +
  • + {{.RepoName}}: {{.Error}} +
  • + {{end}} +
+
+ {{end}} +
+
+ + {{if .Result.MigratedRepos}} +
+

{{ctx.Locale.Tr "org.migrate.migrated_repos_list"}}

+
    + {{range .Result.MigratedRepos}} +
  • {{.}}
  • + {{end}} +
+
+ {{end}} + + +
+
+
+{{template "base/footer" .}} diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index fab51203d1c4c..bcdb9e0dcb369 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -1837,6 +1837,41 @@ } } }, + "/orgs/migrate": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "organization" + ], + "summary": "Migrate an organization's repositories", + "operationId": "orgMigrate", + "parameters": [ + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/MigrateOrgOptions" + } + } + ], + "responses": { + "201": { + "$ref": "#/responses/OrgMigrationResult" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "422": { + "$ref": "#/responses/validationError" + } + } + } + }, "/orgs/{org}": { "get": { "produces": [ @@ -25968,6 +26003,12 @@ }, "x-go-package": "code.gitea.io/gitea/modules/structs" }, + "GitServiceType": { + "description": "GitServiceType represents a git service", + "type": "integer", + "format": "int64", + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, "GitTreeResponse": { "description": "GitTreeResponse returns a git tree", "type": "object", @@ -26692,6 +26733,93 @@ }, "x-go-package": "code.gitea.io/gitea/modules/structs" }, + "MigrateOrgOptions": { + "description": "MigrateOrgOptions options for migrating an organization's repositories\nthis is used to interact with api v1", + "type": "object", + "required": [ + "clone_addr", + "target_org_name", + "source_org_name" + ], + "properties": { + "auth_password": { + "type": "string", + "x-go-name": "AuthPassword" + }, + "auth_token": { + "type": "string", + "x-go-name": "AuthToken" + }, + "auth_username": { + "type": "string", + "x-go-name": "AuthUsername" + }, + "clone_addr": { + "type": "string", + "x-go-name": "CloneAddr" + }, + "issues": { + "type": "boolean", + "x-go-name": "Issues" + }, + "labels": { + "type": "boolean", + "x-go-name": "Labels" + }, + "lfs": { + "type": "boolean", + "x-go-name": "LFS" + }, + "lfs_endpoint": { + "type": "string", + "x-go-name": "LFSEndpoint" + }, + "milestones": { + "type": "boolean", + "x-go-name": "Milestones" + }, + "mirror": { + "type": "boolean", + "x-go-name": "Mirror" + }, + "mirror_interval": { + "type": "string", + "x-go-name": "MirrorInterval" + }, + "private": { + "type": "boolean", + "x-go-name": "Private" + }, + "pull_requests": { + "type": "boolean", + "x-go-name": "PullRequests" + }, + "release_assets": { + "type": "boolean", + "x-go-name": "ReleaseAssets" + }, + "releases": { + "type": "boolean", + "x-go-name": "Releases" + }, + "service": { + "$ref": "#/definitions/GitServiceType" + }, + "source_org_name": { + "type": "string", + "x-go-name": "SourceOrgName" + }, + "target_org_name": { + "type": "string", + "x-go-name": "TargetOrgName" + }, + "wiki": { + "type": "boolean", + "x-go-name": "Wiki" + } + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, "MigrateRepoOptions": { "description": "MigrateRepoOptions options for migrating repository's\nthis is used to interact with api v1", "type": "object", @@ -27177,6 +27305,47 @@ }, "x-go-package": "code.gitea.io/gitea/modules/structs" }, + "OrgMigrationFailure": { + "description": "OrgMigrationFailure represents a failed repository migration", + "type": "object", + "properties": { + "error": { + "type": "string", + "x-go-name": "Error" + }, + "repo_name": { + "type": "string", + "x-go-name": "RepoName" + } + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, + "OrgMigrationResult": { + "description": "OrgMigrationResult represents the result of an organization migration", + "type": "object", + "properties": { + "failed_repos": { + "type": "array", + "items": { + "$ref": "#/definitions/OrgMigrationFailure" + }, + "x-go-name": "FailedRepos" + }, + "migrated_repos": { + "type": "array", + "items": { + "type": "string" + }, + "x-go-name": "MigratedRepos" + }, + "total_repos": { + "type": "integer", + "format": "int64", + "x-go-name": "TotalRepos" + } + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, "Organization": { "description": "Organization represents an organization", "type": "object", @@ -30272,6 +30441,12 @@ } } }, + "OrgMigrationResult": { + "description": "OrgMigrationResult", + "schema": { + "$ref": "#/definitions/OrgMigrationResult" + } + }, "Organization": { "description": "Organization", "schema": { diff --git a/tests/integration/api_org_migrate_test.go b/tests/integration/api_org_migrate_test.go new file mode 100644 index 0000000000000..15c1d4d6ffb9a --- /dev/null +++ b/tests/integration/api_org_migrate_test.go @@ -0,0 +1,198 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "net/http" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/structs" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +func TestAPIOrgMigrate(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + t.Run("NoAuth", func(t *testing.T) { + req := NewRequestWithJSON(t, "POST", "/api/v1/orgs/migrate", &api.MigrateOrgOptions{ + CloneAddr: "https://github.com", + SourceOrgName: "test-org", + TargetOrgName: "org3", + Service: structs.GithubService, + }) + MakeRequest(t, req, http.StatusUnauthorized) + }) + + t.Run("TargetOrgNotExist", func(t *testing.T) { + session := loginUser(t, "user1") + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteOrganization, auth_model.AccessTokenScopeWriteRepository) + + req := NewRequestWithJSON(t, "POST", "/api/v1/orgs/migrate", &api.MigrateOrgOptions{ + CloneAddr: "https://github.com", + SourceOrgName: "test-org", + TargetOrgName: "nonexistent-org", + Service: structs.GithubService, + }).AddTokenAuth(token) + session.MakeRequest(t, req, http.StatusUnprocessableEntity) + }) + + t.Run("NotOrgOwner", func(t *testing.T) { + session := loginUser(t, "user4") + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteOrganization, auth_model.AccessTokenScopeWriteRepository) + + req := NewRequestWithJSON(t, "POST", "/api/v1/orgs/migrate", &api.MigrateOrgOptions{ + CloneAddr: "https://github.com", + SourceOrgName: "test-org", + TargetOrgName: "org3", + Service: structs.GithubService, + }).AddTokenAuth(token) + session.MakeRequest(t, req, http.StatusForbidden) + }) + + t.Run("ValidRequest", func(t *testing.T) { + session := loginUser(t, "user2") + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteOrganization, auth_model.AccessTokenScopeWriteRepository) + + org := unittest.AssertExistsAndLoadBean(t, &user_model.User{LowerName: "org3", Type: user_model.UserTypeOrganization}) + assert.NotNil(t, org) + + req := NewRequestWithJSON(t, "POST", "/api/v1/orgs/migrate", &api.MigrateOrgOptions{ + CloneAddr: "https://github.com", + SourceOrgName: "go-gitea", + TargetOrgName: "org3", + Service: structs.GithubService, + AuthToken: "test-token", + Private: false, + Wiki: true, + Issues: false, + PullRequests: false, + Releases: true, + }).AddTokenAuth(token) + + resp := session.MakeRequest(t, req, NoExpectedStatus) + assert.NotEqual(t, http.StatusUnauthorized, resp.Code) + assert.NotEqual(t, http.StatusForbidden, resp.Code) + assert.NotEqual(t, http.StatusUnprocessableEntity, resp.Code) + assert.NotEqual(t, http.StatusBadRequest, resp.Code) + + if resp.Code == http.StatusCreated { + var result api.OrgMigrationResult + DecodeJSON(t, resp, &result) + assert.GreaterOrEqual(t, result.TotalRepos, 0) + } + }) +} + +func TestWebOrgMigrate(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + t.Run("RequiresLogin", func(t *testing.T) { + req := NewRequest(t, "GET", "/org/migrate") + MakeRequest(t, req, http.StatusSeeOther) + }) + + t.Run("PageLoads", func(t *testing.T) { + session := loginUser(t, "user1") + req := NewRequest(t, "GET", "/org/migrate") + resp := session.MakeRequest(t, req, http.StatusOK) + + htmlDoc := NewHTMLParser(t, resp.Body) + AssertHTMLElement(t, htmlDoc, "form.ui.form", true) + AssertHTMLElement(t, htmlDoc, "#clone_addr", true) + AssertHTMLElement(t, htmlDoc, "#source_org_name", true) + AssertHTMLElement(t, htmlDoc, "#target_org_name", true) + }) + + t.Run("MissingFields", func(t *testing.T) { + session := loginUser(t, "user1") + + req := NewRequestWithValues(t, "POST", "/org/migrate", map[string]string{}) + session.MakeRequest(t, req, http.StatusOK) + }) + + t.Run("ValidFormSubmission", func(t *testing.T) { + session := loginUser(t, "user1") + + _ = unittest.AssertExistsAndLoadBean(t, &user_model.User{LowerName: "org3", Type: user_model.UserTypeOrganization}) + + req := NewRequestWithValues(t, "POST", "/org/migrate", map[string]string{ + "clone_addr": "https://github.com", + "source_org_name": "go-gitea", + "target_org_name": "org3", + "service": "2", + "auth_token": "", + }) + resp := session.MakeRequest(t, req, NoExpectedStatus) + assert.NotEqual(t, http.StatusInternalServerError, resp.Code) + }) +} + +func TestAPIOrgMigrateServiceTypes(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + session := loginUser(t, "user1") + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteOrganization, auth_model.AccessTokenScopeWriteRepository) + + serviceTypes := []struct { + name string + service structs.GitServiceType + }{ + {"Git", structs.PlainGitService}, + {"GitHub", structs.GithubService}, + {"GitLab", structs.GitlabService}, + {"Gitea", structs.GiteaService}, + {"Gogs", structs.GogsService}, + } + + for _, st := range serviceTypes { + t.Run(st.name, func(t *testing.T) { + req := NewRequestWithJSON(t, "POST", "/api/v1/orgs/migrate", &api.MigrateOrgOptions{ + CloneAddr: "https://example.com", + SourceOrgName: "test", + TargetOrgName: "org3", + Service: st.service, + }).AddTokenAuth(token) + + resp := session.MakeRequest(t, req, NoExpectedStatus) + assert.NotEqual(t, http.StatusUnprocessableEntity, resp.Code, "Service type %s was rejected as invalid", st.name) + assert.NotEqual(t, http.StatusBadRequest, resp.Code, "Service type %s was rejected as invalid", st.name) + }) + } +} + +func TestAPIOrgMigrateURLValidation(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + session := loginUser(t, "user1") + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteOrganization, auth_model.AccessTokenScopeWriteRepository) + + urlTests := []struct { + name string + cloneAddr string + expectCode int + }{ + {"EmptyURL", "", http.StatusUnprocessableEntity}, + {"InvalidURL", "not-a-url", http.StatusUnprocessableEntity}, + {"Localhost", "http://localhost/test", http.StatusUnprocessableEntity}, + } + + for _, tt := range urlTests { + t.Run(tt.name, func(t *testing.T) { + req := NewRequestWithJSON(t, "POST", "/api/v1/orgs/migrate", &api.MigrateOrgOptions{ + CloneAddr: tt.cloneAddr, + SourceOrgName: "test", + TargetOrgName: "org3", + Service: structs.GithubService, + }).AddTokenAuth(token) + + session.MakeRequest(t, req, tt.expectCode) + }) + } +} diff --git a/web_src/js/features/repo-migration.ts b/web_src/js/features/repo-migration.ts index 7376ff7fa4f8e..57f11f76932e6 100644 --- a/web_src/js/features/repo-migration.ts +++ b/web_src/js/features/repo-migration.ts @@ -11,7 +11,76 @@ const lfsSettings = document.querySelector('#lfs_settings')!; const lfsEndpoint = document.querySelector('#lfs_endpoint')!; const items = document.querySelectorAll('#migrate_items input[type=checkbox]'); +export function initOrgMigration() { + const orgService = document.querySelector('#service'); + const orgToken = document.querySelector('#auth_token'); + const orgUser = document.querySelector('#auth_username'); + const orgPass = document.querySelector('#auth_password'); + if (!orgToken || !orgService) return; + if (!document.querySelector('.page-content.organization.migrate')) return; + + const orgTokenField = document.querySelector('#auth_token_field'); + const orgUserField = document.querySelector('#auth_username_field'); + const orgPassField = document.querySelector('#auth_password_field'); + const orgItems = document.querySelectorAll('#migrate_items input[type=checkbox]'); + + // Service types that support token auth (same as TokenAuth() in structs) + // GithubService = 2, GiteaService = 3, GitlabService = 4 + const tokenAuthServices = [2, 3, 4]; + + const checkOrgAuthFields = () => { + const serviceType = Number(orgService.value); + const useTokenAuth = tokenAuthServices.includes(serviceType); + + if (orgTokenField) toggleElem(orgTokenField, useTokenAuth); + if (orgUserField) toggleElem(orgUserField, !useTokenAuth); + if (orgPassField) toggleElem(orgPassField, !useTokenAuth); + }; + + const checkOrgItems = () => { + const serviceType = Number(orgService.value); + const useTokenAuth = tokenAuthServices.includes(serviceType); + + let enable: boolean; + if (useTokenAuth) { + enable = orgToken.value !== ''; + } else { + enable = (orgUser?.value !== '') || (orgPass?.value !== ''); + } + for (const item of orgItems) item.disabled = !enable; + }; + + checkOrgAuthFields(); + checkOrgItems(); + + orgService.addEventListener('change', () => { + checkOrgAuthFields(); + checkOrgItems(); + }); + orgToken.addEventListener('input', checkOrgItems); + orgUser?.addEventListener('input', checkOrgItems); + orgPass?.addEventListener('input', checkOrgItems); + + const orgLfs = document.querySelector('#lfs'); + const orgLfsSettings = document.querySelector('#lfs_settings'); + const orgLfsEndpoint = document.querySelector('#lfs_endpoint'); + if (orgLfs && orgLfsSettings && orgLfsEndpoint) { + const setOrgLFSVisibility = () => { + toggleElem(orgLfsSettings, orgLfs.checked); + hideElem(orgLfsEndpoint); + }; + setOrgLFSVisibility(); + orgLfs.addEventListener('change', setOrgLFSVisibility); + document.querySelector('#lfs_settings_show')?.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + showElem(orgLfsEndpoint); + }); + } +} + export function initRepoMigration() { + if (!document.querySelector('.page-content.repository.migrate')) return; checkAuth(); setLFSSettingsVisibility(); diff --git a/web_src/js/index-domready.ts b/web_src/js/index-domready.ts index fb445b8df42a2..20efa96682420 100644 --- a/web_src/js/index-domready.ts +++ b/web_src/js/index-domready.ts @@ -6,7 +6,7 @@ import {initGlobalCopyToClipboardListener} from './features/clipboard.ts'; import {initRepoGraphGit} from './features/repo-graph.ts'; import {initHeatmap} from './features/heatmap.ts'; import {initImageDiff} from './features/imagediff.ts'; -import {initRepoMigration} from './features/repo-migration.ts'; +import {initOrgMigration, initRepoMigration} from './features/repo-migration.ts'; import {initRepoProject} from './features/repo-projects.ts'; import {initTableSort} from './features/tablesort.ts'; import {initAdminUserListSearchForm} from './features/admin/users.ts'; @@ -129,6 +129,7 @@ const initPerformanceTracer = callInitFunctions([ initRepoIssueList, initRepoIssueFilterItemLabel, initRepoIssueSidebarDependency, + initOrgMigration, initRepoMigration, initRepoMigrationStatusChecker, initRepoProject,