Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions modules/migration/downloader.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions modules/migration/null_downloader.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
}
55 changes: 55 additions & 0 deletions modules/structs/repo.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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"`
Expand Down
26 changes: 26 additions & 0 deletions options/locale/locale_en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions routers/api/v1/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
151 changes: 151 additions & 0 deletions routers/api/v1/org/migrate.go
Original file line number Diff line number Diff line change
@@ -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)
}
2 changes: 2 additions & 0 deletions routers/api/v1/swagger/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,8 @@ type swaggerParameterBodies struct {
// in:body
MigrateRepoOptions api.MigrateRepoOptions

// in:body
MigrateOrgOptions api.MigrateOrgOptions
// in:body
PullReviewRequestOptions api.PullReviewRequestOptions

Expand Down
7 changes: 7 additions & 0 deletions routers/api/v1/swagger/org.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}
Loading
Loading