From ae19a87b71e0eb80757079c23b9ff9a424a2c229 Mon Sep 17 00:00:00 2001 From: 6543 <6543@obermui.de> Date: Thu, 23 Apr 2026 10:02:09 +0200 Subject: [PATCH] Handle re-created forge repos gracefully (#6370) Co-authored-by: qwerty287 <80460567+qwerty287@users.noreply.github.com> --- cmd/server/openapi/docs.go | 16 +++++++ server/api/repo.go | 17 +++++++- server/api/user.go | 39 ++++++++++++++--- server/model/repo.go | 8 ++++ web/src/assets/locales/en.json | 7 ++- web/src/lib/api/types/repo.ts | 6 +++ web/src/views/RepoAdd.vue | 79 +++++++++++++++++++++++++++------- 7 files changed, 147 insertions(+), 25 deletions(-) diff --git a/cmd/server/openapi/docs.go b/cmd/server/openapi/docs.go index 977ae65b5c..8caf9e5204 100644 --- a/cmd/server/openapi/docs.go +++ b/cmd/server/openapi/docs.go @@ -5270,6 +5270,14 @@ const docTemplate = `{ "full_name": { "type": "string" }, + "has_forge_name_conflict": { + "description": "HasForgeNameConflict is true if forge returned a repo with same name but different forge remote id", + "type": "boolean" + }, + "has_no_forge_repo": { + "description": "HasNoForgeRepo is true if repo only exist in the woodpecker store and not at the forge anymore", + "type": "boolean" + }, "id": { "type": "integer" }, @@ -5381,6 +5389,14 @@ const docTemplate = `{ "full_name": { "type": "string" }, + "has_forge_name_conflict": { + "description": "HasForgeNameConflict is true if forge returned a repo with same name but different forge remote id", + "type": "boolean" + }, + "has_no_forge_repo": { + "description": "HasNoForgeRepo is true if repo only exist in the woodpecker store and not at the forge anymore", + "type": "boolean" + }, "id": { "type": "integer" }, diff --git a/server/api/repo.go b/server/api/repo.go index 3a6b89e23e..9fc14ff3f8 100644 --- a/server/api/repo.go +++ b/server/api/repo.go @@ -740,9 +740,22 @@ func repairRepo(c *gin.Context, repo *model.Repo, updatePermissions bool) error from, err := _forge.Repo(c, repoUser, repo.ForgeRemoteID, repo.Owner, repo.Name) if err != nil { - log.Error().Err(err).Msgf("get repo '%s/%s' from forge", repo.Owner, repo.Name) - return err + // If we have valid ForgeRemoteID and can not find the repo, + // we assume the repo was deleted and try to get a new one if it was re-created. + if errors.Is(err, forge_types.ErrRepoNotFound) && repo.ForgeRemoteID.IsValid() { + from, err = _forge.Repo(c, repoUser, "", repo.Owner, repo.Name) + if err == nil { + log.Debug().Str("repoFullName", repo.FullName). + Str("old ForgeRemoteID", string(repo.ForgeRemoteID)).Str("new ForgeRemoteID", string(from.ForgeRemoteID)). + Msgf("RepoRepair detected remote repo ID change and updated it") + } + } } + if err != nil { + log.Error().Err(err).Msgf("get repo '%s/%s' from forge", repo.Owner, repo.Name) + return fmt.Errorf("fetching repo from forge: %w", err) + } + from.ForgeID = repo.ForgeID if repo.FullName != from.FullName { diff --git a/server/api/user.go b/server/api/user.go index 24dc359607..64b8010c2c 100644 --- a/server/api/user.go +++ b/server/api/user.go @@ -18,6 +18,7 @@ import ( "encoding/base32" "net/http" "strconv" + "strings" "github.com/gin-gonic/gin" "github.com/rs/zerolog/log" @@ -109,9 +110,13 @@ func GetRepos(c *gin.Context) { return } - active := map[model.ForgeRemoteID]*model.Repo{} + dbReposMap := map[model.ForgeRemoteID]*model.Repo{} + dbStaleReposMap := map[int64]*model.Repo{} + dbReposFullNameMap := map[string]*model.Repo{} for _, r := range dbRepos { - active[r.ForgeRemoteID] = r + dbReposMap[r.ForgeRemoteID] = r + dbReposFullNameMap[strings.ToLower(r.FullName)] = r + dbStaleReposMap[r.ID] = r } _repos, err := utils.Paginate(func(page int) ([]*model.Repo, error) { @@ -131,18 +136,40 @@ func GetRepos(c *gin.Context) { r.ForgeID = user.ForgeID if r.Perm.Push && server.Config.Permissions.OwnersAllowlist.IsAllowed(r) { - if active[r.ForgeRemoteID] != nil { - existingRepo := active[r.ForgeRemoteID] + if existingRepo := dbReposMap[r.ForgeRemoteID]; existingRepo != nil { + // update repo with forge response existingRepo.Update(r) - existingRepo.IsActive = active[r.ForgeRemoteID].IsActive + // re-apply active info + existingRepo.IsActive = dbReposMap[r.ForgeRemoteID].IsActive + // add to final return list repos = append(repos, existingRepo) + // not stale, so remove it + delete(dbStaleReposMap, existingRepo.ID) } else if r.Perm.Admin { - // you must be admin to enable the repo + // you must be admin of the remote repo to enable the repo repos = append(repos, r) } } } + // detect conflicts + for _, r := range repos { + // calc if we have a remote repo with different remote id but same name as a stored one + if existingRepo := dbReposFullNameMap[strings.ToLower(r.FullName)]; existingRepo != nil && existingRepo.ForgeRemoteID != r.ForgeRemoteID { + r.ID = existingRepo.ID + r.HasForgeNameConflict = true + + // not stale, so remove it + delete(dbStaleReposMap, existingRepo.ID) + } + } + + // return stale repos + for _, staleRepo := range dbStaleReposMap { + staleRepo.HasNoForgeRepo = true + repos = append(repos, staleRepo) + } + c.JSON(http.StatusOK, repos) return } diff --git a/server/model/repo.go b/server/model/repo.go index 9f0a6c4849..b2eac4f3de 100644 --- a/server/model/repo.go +++ b/server/model/repo.go @@ -79,6 +79,14 @@ type Repo struct { SecretExtensionEndpoint string `json:"secret_extension_endpoint" xorm:"varchar(500) 'secret_extension_endpoint'"` SecretExtensionNetrc bool `json:"secret_extension_netrc" xorm:"DEFAULT FALSE 'secret_extension_netrc'"` + // Rest API Only + + // HasForgeNameConflict is true if forge returned a repo with same name but different forge remote id + HasForgeNameConflict bool `json:"has_forge_name_conflict,omitempty" xorm:"-"` + + // HasNoForgeRepo is true if repo only exist in the woodpecker store and not at the forge anymore + HasNoForgeRepo bool ` json:"has_no_forge_repo,omitempty" xorm:"-"` + // internal usage Perm *Perm `json:"-" xorm:"-"` diff --git a/web/src/assets/locales/en.json b/web/src/assets/locales/en.json index df93e2052f..d63d76b0c9 100644 --- a/web/src/assets/locales/en.json +++ b/web/src/assets/locales/en.json @@ -74,7 +74,12 @@ "enable": "Enable", "enabled": "Already enabled", "disabled": "Disabled", - "success": "Repository enabled" + "success": "Repository enabled", + "new_forge_repo": "new repo on forge", + "stale_wp_repo": "outdated Woodpecker repo", + "conflict": "Conflict", + "conflict_desc": "This repository was recreated on the forge with a new ID, but an outdated entry with the same name still exists in Woodpecker. Delete the outdated to enable the new one or Repair the old.", + "forge_repo_missing": "Forge repo is missing!" }, "open_in_forge": "Open repository in forge", "visibility": { diff --git a/web/src/lib/api/types/repo.ts b/web/src/lib/api/types/repo.ts index 79aa0d4a09..168a713070 100644 --- a/web/src/lib/api/types/repo.ts +++ b/web/src/lib/api/types/repo.ts @@ -99,6 +99,12 @@ export interface Repo { // Whether to include netrc credentials in secret extension requests secret_extension_netrc: boolean; + + // True if forge returned a repo with same name but different forge remote id + has_forge_name_conflict?: boolean; + + // True if repo only exist in the woodpecker store and not at the forge anymore + has_no_forge_repo?: boolean; } /* eslint-disable no-unused-vars */ diff --git a/web/src/views/RepoAdd.vue b/web/src/views/RepoAdd.vue index 09ffc782f3..69faef9ad0 100644 --- a/web/src/views/RepoAdd.vue +++ b/web/src/views/RepoAdd.vue @@ -6,23 +6,70 @@