1
0
mirror of https://github.com/woodpecker-ci/woodpecker.git synced 2026-06-03 16:35:37 +02:00

Handle re-created forge repos gracefully (#6370)

Co-authored-by: qwerty287 <80460567+qwerty287@users.noreply.github.com>
This commit is contained in:
6543
2026-04-23 10:02:09 +02:00
committed by GitHub
parent 46b73078e9
commit ae19a87b71
7 changed files with 147 additions and 25 deletions
+16
View File
@@ -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"
},
+15 -2
View File
@@ -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 {
+33 -6
View File
@@ -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
}
+8
View File
@@ -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:"-"`
+6 -1
View File
@@ -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": {
+6
View File
@@ -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 */
+63 -16
View File
@@ -6,23 +6,70 @@
<div class="space-y-4">
<template v-if="repos !== undefined && repos.length > 0">
<ListItem
v-for="repo in searchedRepos"
:key="repo.id"
class="items-center"
:to="repo.active ? { name: 'repo', params: { repoId: repo.id } } : undefined"
>
<span class="text-wp-text-100">{{ repo.full_name }}</span>
<span v-if="repo.active" class="text-wp-text-alt-100 ml-auto">{{ $t('repo.enable.enabled') }}</span>
<div v-else class="ml-auto flex items-center">
<Badge v-if="repo.id" class="md:display-unset mr-2 hidden" :value="$t('repo.enable.disabled')" />
<Button
:text="$t('repo.enable.enable')"
:is-loading="isActivatingRepo && repoToActivate?.forge_remote_id === repo.forge_remote_id"
@click="activateRepo(repo)"
/>
<template v-for="repo in searchedRepos" :key="repo.forge_remote_id">
<!-- Conflict case: forge repo exists but a stale Woodpecker repo with same name blocks activation -->
<div v-if="repo.has_forge_name_conflict" class="space-y-0">
<!-- New forge repo (that causes conflict) -->
<ListItem class="items-center rounded-b-none! border-b-0!">
<div class="flex w-full items-center">
<span class="text-wp-text-100">{{ repo.full_name }}</span>
<span class="text-wp-text-alt-100 ml-2 text-xs">{{ $t('repo.enable.new_forge_repo') }}</span>
<div class="ml-auto flex items-center">
<Button :text="$t('repo.enable.conflict')" :title="$t('repo.enable.conflict_desc')" disabled />
</div>
</div>
</ListItem>
<!-- Old stale Woodpecker repo -->
<ListItem
:to="{ name: 'repo', params: { repoId: repo.id } }"
class="items-center rounded-t-none! border-t-0! opacity-80"
>
<span class="text-wp-text-alt-100">{{ repo.full_name }}</span>
<span class="text-wp-text-alt-100 ml-2 text-xs">{{ $t('repo.enable.stale_wp_repo') }}</span>
<div class="ml-auto" @click.prevent.stop>
<Button
start-icon="toolbox"
:text="$t('repo.settings.actions.actions')"
:to="{ name: 'repo-settings-actions', params: { repoId: repo.id } }"
/>
</div>
</ListItem>
</div>
</ListItem>
<!-- Conflict case: has no forge counterpart -->
<ListItem
v-else-if="repo.has_no_forge_repo"
:to="{ name: 'repo', params: { repoId: repo.id } }"
class="items-center"
>
<span class="text-wp-text-100">{{ repo.full_name }}</span>
<span class="text-wp-text-alt-100 ml-auto">{{ $t('repo.enable.forge_repo_missing') }}</span>
</ListItem>
<!-- Normal case: already active -->
<ListItem v-else-if="repo.active" :to="{ name: 'repo', params: { repoId: repo.id } }" class="items-center">
<span class="text-wp-text-100">{{ repo.full_name }}</span>
<span class="text-wp-text-alt-100 ml-auto">{{ $t('repo.enable.enabled') }}</span>
</ListItem>
<!-- Normal case: can be enabled -->
<ListItem
v-else
class="items-center"
:to="repo.id ? { name: 'repo', params: { repoId: repo.id } } : undefined"
>
<span class="text-wp-text-100">{{ repo.full_name }}</span>
<div class="ml-auto flex items-center">
<Badge v-if="repo.id" class="md:display-unset mr-2 hidden" :value="$t('repo.enable.disabled')" />
<Button
:text="$t('repo.enable.enable')"
:is-loading="isActivatingRepo && repoToActivate?.forge_remote_id === repo.forge_remote_id"
@click="activateRepo(repo)"
/>
</div>
</ListItem>
</template>
</template>
<div v-else-if="loading" class="text-wp-text-100 flex justify-center">
<Icon name="spinner" />