workflow-pr: rename package branches when a package is removed
Integration (Selective) / check (pull_request) Successful in 2s
gofmt / Format checking (pull_request) Successful in 4s
go-generate-check / go-generate-check (pull_request) Successful in 12s
gofmt / Linter and static analysis (pull_request) Successful in 59s
go-test-unit / go-test-unit (pull_request) Successful in 1m1s
Integration (Selective) / integration (pull_request) Successful in 9m14s

Implement the core of the package-removal workflow: when a package's
submodule disappears from the project git, rename that package's project
branch to its "-removed" variant (e.g. "factory" -> "factory-removed").
Renaming is anchored on the submodule disappearing, not on the removal
issue, so it also happens when the issue process is bypassed.

- common: add a RenameBranch Gitea primitive (wrapper over
  RepoRenameBranch) exposed through a small GiteaBranchRenamer interface
  and the master Gitea interface, with regenerated mocks. Renaming the
  default branch is handled by Gitea itself (it reassigns the default),
  so no SetDefaultBranch is needed.

- common: add DetectRemovedSubmodules (diff two project-git submodule
  lists) and RenameRemovedPackageBranches (best-effort, idempotent: skip
  packages whose branch is already gone, resolve the default branch when
  config.Branch is empty, and keep going if one package fails).

- workflow-pr: hook the rename into the two points where a submodule can
  disappear: after a ProjectGit PR merge (reusing the already computed
  removed-submodule set) and in the consistency check (comparing the
  previous project HEAD with the freshly cloned one), the latter
  covering bypassed removals such as a direct push.

Assisted-by: Anthropic:claude-opus-4.8
This commit is contained in:
2026-07-20 19:14:24 +02:00
parent bdaada0b26
commit 276b9da3be
7 changed files with 425 additions and 1 deletions
+22
View File
@@ -205,6 +205,11 @@ type GiteaRepoReparentAccepter interface {
AcceptRepoReparent(owner, repo string) (*models.Repository, error)
}
type GiteaBranchRenamer interface {
Context() context.Context
RenameBranch(org, repo, oldName, newName string) error
}
type GiteaFileContentReader interface {
Context() context.Context
GetRepositoryFileContent(org, repo, hash, path string) ([]byte, string, error)
@@ -257,6 +262,7 @@ type Gitea interface {
GiteaLabelRemover
GiteaIssueFetcher
GiteaRepoReparentAccepter
GiteaBranchRenamer
GetNotifications(Type string, since *time.Time) ([]*models.NotificationThread, error)
GetDoneNotifications(Type string, page int64) ([]*models.NotificationThread, error)
@@ -1230,6 +1236,22 @@ func (gitea *GiteaTransport) DeleteBranch(org, repo, branch string) error {
return err
}
func (gitea *GiteaTransport) RenameBranch(org, repo, oldName, newName string) error {
_, err := gitea.client.Repository.RepoRenameBranch(
repository.NewRepoRenameBranchParams().
WithOwner(org).
WithRepo(repo).
WithBranch(oldName).
WithBody(&models.RenameBranchRepoOption{Name: &newName}),
gitea.transport.DefaultAuthentication,
)
if err != nil {
LogErrorCtx(gitea.Context(), err)
return err
}
return nil
}
func (gitea *GiteaTransport) GetRecentCommits(org, repo, branch string, commitNo int64) ([]*models.Commit, error) {
not := false
var page int64 = 1
+138
View File
@@ -3732,6 +3732,106 @@ func (c *MockGiteaRepoReparentAccepterContextCall) DoAndReturn(f func() context.
return c
}
// MockGiteaBranchRenamer is a mock of GiteaBranchRenamer interface.
type MockGiteaBranchRenamer struct {
ctrl *gomock.Controller
recorder *MockGiteaBranchRenamerMockRecorder
isgomock struct{}
}
// MockGiteaBranchRenamerMockRecorder is the mock recorder for MockGiteaBranchRenamer.
type MockGiteaBranchRenamerMockRecorder struct {
mock *MockGiteaBranchRenamer
}
// NewMockGiteaBranchRenamer creates a new mock instance.
func NewMockGiteaBranchRenamer(ctrl *gomock.Controller) *MockGiteaBranchRenamer {
mock := &MockGiteaBranchRenamer{ctrl: ctrl}
mock.recorder = &MockGiteaBranchRenamerMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockGiteaBranchRenamer) EXPECT() *MockGiteaBranchRenamerMockRecorder {
return m.recorder
}
// Context mocks base method.
func (m *MockGiteaBranchRenamer) Context() context.Context {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Context")
ret0, _ := ret[0].(context.Context)
return ret0
}
// Context indicates an expected call of Context.
func (mr *MockGiteaBranchRenamerMockRecorder) Context() *MockGiteaBranchRenamerContextCall {
mr.mock.ctrl.T.Helper()
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Context", reflect.TypeOf((*MockGiteaBranchRenamer)(nil).Context))
return &MockGiteaBranchRenamerContextCall{Call: call}
}
// MockGiteaBranchRenamerContextCall wrap *gomock.Call
type MockGiteaBranchRenamerContextCall struct {
*gomock.Call
}
// Return rewrite *gomock.Call.Return
func (c *MockGiteaBranchRenamerContextCall) Return(arg0 context.Context) *MockGiteaBranchRenamerContextCall {
c.Call = c.Call.Return(arg0)
return c
}
// Do rewrite *gomock.Call.Do
func (c *MockGiteaBranchRenamerContextCall) Do(f func() context.Context) *MockGiteaBranchRenamerContextCall {
c.Call = c.Call.Do(f)
return c
}
// DoAndReturn rewrite *gomock.Call.DoAndReturn
func (c *MockGiteaBranchRenamerContextCall) DoAndReturn(f func() context.Context) *MockGiteaBranchRenamerContextCall {
c.Call = c.Call.DoAndReturn(f)
return c
}
// RenameBranch mocks base method.
func (m *MockGiteaBranchRenamer) RenameBranch(org, repo, oldName, newName string) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "RenameBranch", org, repo, oldName, newName)
ret0, _ := ret[0].(error)
return ret0
}
// RenameBranch indicates an expected call of RenameBranch.
func (mr *MockGiteaBranchRenamerMockRecorder) RenameBranch(org, repo, oldName, newName any) *MockGiteaBranchRenamerRenameBranchCall {
mr.mock.ctrl.T.Helper()
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RenameBranch", reflect.TypeOf((*MockGiteaBranchRenamer)(nil).RenameBranch), org, repo, oldName, newName)
return &MockGiteaBranchRenamerRenameBranchCall{Call: call}
}
// MockGiteaBranchRenamerRenameBranchCall wrap *gomock.Call
type MockGiteaBranchRenamerRenameBranchCall struct {
*gomock.Call
}
// Return rewrite *gomock.Call.Return
func (c *MockGiteaBranchRenamerRenameBranchCall) Return(arg0 error) *MockGiteaBranchRenamerRenameBranchCall {
c.Call = c.Call.Return(arg0)
return c
}
// Do rewrite *gomock.Call.Do
func (c *MockGiteaBranchRenamerRenameBranchCall) Do(f func(string, string, string, string) error) *MockGiteaBranchRenamerRenameBranchCall {
c.Call = c.Call.Do(f)
return c
}
// DoAndReturn rewrite *gomock.Call.DoAndReturn
func (c *MockGiteaBranchRenamerRenameBranchCall) DoAndReturn(f func(string, string, string, string) error) *MockGiteaBranchRenamerRenameBranchCall {
c.Call = c.Call.DoAndReturn(f)
return c
}
// MockGiteaFileContentReader is a mock of GiteaFileContentReader interface.
type MockGiteaFileContentReader struct {
ctrl *gomock.Controller
@@ -5523,6 +5623,44 @@ func (c *MockGiteaRemoveLabelCall) DoAndReturn(f func(string, string, int64, str
return c
}
// RenameBranch mocks base method.
func (m *MockGitea) RenameBranch(org, repo, oldName, newName string) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "RenameBranch", org, repo, oldName, newName)
ret0, _ := ret[0].(error)
return ret0
}
// RenameBranch indicates an expected call of RenameBranch.
func (mr *MockGiteaMockRecorder) RenameBranch(org, repo, oldName, newName any) *MockGiteaRenameBranchCall {
mr.mock.ctrl.T.Helper()
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RenameBranch", reflect.TypeOf((*MockGitea)(nil).RenameBranch), org, repo, oldName, newName)
return &MockGiteaRenameBranchCall{Call: call}
}
// MockGiteaRenameBranchCall wrap *gomock.Call
type MockGiteaRenameBranchCall struct {
*gomock.Call
}
// Return rewrite *gomock.Call.Return
func (c *MockGiteaRenameBranchCall) Return(arg0 error) *MockGiteaRenameBranchCall {
c.Call = c.Call.Return(arg0)
return c
}
// Do rewrite *gomock.Call.Do
func (c *MockGiteaRenameBranchCall) Do(f func(string, string, string, string) error) *MockGiteaRenameBranchCall {
c.Call = c.Call.Do(f)
return c
}
// DoAndReturn rewrite *gomock.Call.DoAndReturn
func (c *MockGiteaRenameBranchCall) DoAndReturn(f func(string, string, string, string) error) *MockGiteaRenameBranchCall {
c.Call = c.Call.DoAndReturn(f)
return c
}
// RequestReviews mocks base method.
func (m *MockGitea) RequestReviews(pr *models.PullRequest, reviewer ...string) ([]*models.PullReview, error) {
m.ctrl.T.Helper()
+96
View File
@@ -0,0 +1,96 @@
package common
/*
* This file is part of Autogits.
*
* Copyright © 2024 SUSE LLC
*
* Autogits is free software: you can redistribute it and/or modify it under
* the terms of the GNU General Public License as published by the Free Software
* Foundation, either version 2 of the License, or (at your option) any later
* version.
*
* Autogits is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
* PARTICULAR PURPOSE. See the GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along with
* Foobar. If not, see <https://www.gnu.org/licenses/>.
*/
import (
"context"
"path"
"src.opensuse.org/autogits/common/gitea-generated/models"
)
// GiteaPackageBranchRemover is the Gitea surface needed to rename a package's
// project branch to its removed-suffix variant: resolve the branch, check it
// still exists and rename it.
type GiteaPackageBranchRemover interface {
Context() context.Context
GiteaBranchRenamer
GetRepository(org, repo string) (*models.Repository, error)
GetRecentCommits(org, repo, branch string, commitNo int64) ([]*models.Commit, error)
}
// DetectRemovedSubmodules returns the submodule paths that exist in fromCommit
// but no longer exist in toCommit of the project git checked out at prjPath.
func DetectRemovedSubmodules(git GitSubmoduleLister, prjPath, fromCommit, toCommit string) ([]string, error) {
before, err := git.GitSubmoduleList(prjPath, fromCommit)
if err != nil {
return nil, err
}
after, err := git.GitSubmoduleList(prjPath, toCommit)
if err != nil {
return nil, err
}
removed := make([]string, 0)
for submodulePath := range before {
if _, found := after[submodulePath]; !found {
removed = append(removed, submodulePath)
}
}
return removed, nil
}
// RenameRemovedPackageBranches renames the project branch of each removed
// package to its removed-suffix variant (e.g. "factory" -> "factory-removed")
// in org. When branch is empty, each package's default branch is used.
//
// It is best-effort and idempotent: packages whose branch no longer exists
// (already renamed) are skipped, and a failure to rename one package does not
// prevent the others from being processed.
func RenameRemovedPackageBranches(ctx context.Context, gitea GiteaPackageBranchRemover, org, branch string, removedPackages []string) {
for _, removedPath := range removedPackages {
pkg := path.Base(removedPath)
LogInfoCtx(ctx, "Detected package removal:", org+"/"+pkg)
pkgBranch := branch
if pkgBranch == "" {
repo, err := gitea.GetRepository(org, pkg)
if err != nil || repo == nil {
LogErrorCtx(ctx, "Cannot fetch repository", org+"/"+pkg, "to resolve default branch:", err)
continue
}
pkgBranch = repo.DefaultBranch
}
commits, err := gitea.GetRecentCommits(org, pkg, pkgBranch, 1)
if err != nil {
LogErrorCtx(ctx, "Cannot check if branch", pkgBranch, "exists in", org+"/"+pkg, ":", err)
continue
}
if len(commits) == 0 {
LogInfoCtx(ctx, "Branch", pkgBranch, "does not exist in", org+"/"+pkg, "(already removed?). Skipping rename.")
continue
}
targetBranch := pkgBranch + RemovedBranchSuffix
if err := gitea.RenameBranch(org, pkg, pkgBranch, targetBranch); err != nil {
LogErrorCtx(ctx, "Failed to rename branch", pkgBranch, "to", targetBranch, "in", org+"/"+pkg, ":", err)
}
}
}
+133
View File
@@ -0,0 +1,133 @@
package common_test
import (
"context"
"fmt"
"slices"
"testing"
"go.uber.org/mock/gomock"
"src.opensuse.org/autogits/common"
"src.opensuse.org/autogits/common/gitea-generated/models"
mock_common "src.opensuse.org/autogits/common/mock"
)
func TestDetectRemovedSubmodules(t *testing.T) {
tests := []struct {
name string
before map[string]string
after map[string]string
removed []string
}{
{
name: "nothing removed",
before: map[string]string{"pkgA": "a1", "pkgB": "b1"},
after: map[string]string{"pkgA": "a1", "pkgB": "b1"},
removed: []string{},
},
{
name: "one removed",
before: map[string]string{"pkgA": "a1", "pkgB": "b1"},
after: map[string]string{"pkgA": "a1"},
removed: []string{"pkgB"},
},
{
name: "multiple removed",
before: map[string]string{"pkgA": "a1", "pkgB": "b1", "pkgC": "c1"},
after: map[string]string{"pkgB": "b1"},
removed: []string{"pkgA", "pkgC"},
},
{
name: "modified is not removed",
before: map[string]string{"pkgA": "a1"},
after: map[string]string{"pkgA": "a2"},
removed: []string{},
},
{
name: "added is not removed",
before: map[string]string{"pkgA": "a1"},
after: map[string]string{"pkgA": "a1", "pkgB": "b1"},
removed: []string{},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
ctl := gomock.NewController(t)
git := mock_common.NewMockGitSubmoduleLister(ctl)
git.EXPECT().GitSubmoduleList("prj", "from").Return(test.before, nil)
git.EXPECT().GitSubmoduleList("prj", "to").Return(test.after, nil)
removed, err := common.DetectRemovedSubmodules(git, "prj", "from", "to")
if err != nil {
t.Fatal("unexpected error:", err)
}
slices.Sort(removed)
if !slices.Equal(removed, test.removed) {
t.Fatalf("expected removed %v, got %v", test.removed, removed)
}
})
}
}
func TestDetectRemovedSubmodulesError(t *testing.T) {
ctl := gomock.NewController(t)
git := mock_common.NewMockGitSubmoduleLister(ctl)
git.EXPECT().GitSubmoduleList("prj", "from").Return(nil, fmt.Errorf("boom"))
if _, err := common.DetectRemovedSubmodules(git, "prj", "from", "to"); err == nil {
t.Fatal("expected error, got nil")
}
}
func TestRenameRemovedPackageBranches(t *testing.T) {
commit := []*models.Commit{{SHA: "abc"}}
t.Run("renames existing branch", func(t *testing.T) {
ctl := gomock.NewController(t)
gitea := mock_common.NewMockGitea(ctl)
gitea.EXPECT().GetRecentCommits("pool", "pkgA", "factory", int64(1)).Return(commit, nil)
gitea.EXPECT().RenameBranch("pool", "pkgA", "factory", "factory-removed").Return(nil)
common.RenameRemovedPackageBranches(context.Background(), gitea, "pool", "factory", []string{"pkgA"})
})
t.Run("skips already-removed branch", func(t *testing.T) {
ctl := gomock.NewController(t)
gitea := mock_common.NewMockGitea(ctl)
// Branch no longer exists -> no RenameBranch call expected.
gitea.EXPECT().GetRecentCommits("pool", "pkgA", "factory", int64(1)).Return(nil, nil)
common.RenameRemovedPackageBranches(context.Background(), gitea, "pool", "factory", []string{"pkgA"})
})
t.Run("uses base name of submodule path", func(t *testing.T) {
ctl := gomock.NewController(t)
gitea := mock_common.NewMockGitea(ctl)
gitea.EXPECT().GetRecentCommits("pool", "pkgA", "factory", int64(1)).Return(commit, nil)
gitea.EXPECT().RenameBranch("pool", "pkgA", "factory", "factory-removed").Return(nil)
common.RenameRemovedPackageBranches(context.Background(), gitea, "pool", "factory", []string{"some/dir/pkgA"})
})
t.Run("resolves default branch when branch is empty", func(t *testing.T) {
ctl := gomock.NewController(t)
gitea := mock_common.NewMockGitea(ctl)
gitea.EXPECT().GetRepository("pool", "pkgA").Return(&models.Repository{DefaultBranch: "factory"}, nil)
gitea.EXPECT().GetRecentCommits("pool", "pkgA", "factory", int64(1)).Return(commit, nil)
gitea.EXPECT().RenameBranch("pool", "pkgA", "factory", "factory-removed").Return(nil)
common.RenameRemovedPackageBranches(context.Background(), gitea, "pool", "", []string{"pkgA"})
})
t.Run("continues after a rename failure", func(t *testing.T) {
ctl := gomock.NewController(t)
gitea := mock_common.NewMockGitea(ctl)
gitea.EXPECT().GetRecentCommits("pool", "pkgA", "factory", int64(1)).Return(commit, nil)
gitea.EXPECT().RenameBranch("pool", "pkgA", "factory", "factory-removed").Return(fmt.Errorf("403"))
gitea.EXPECT().GetRecentCommits("pool", "pkgB", "factory", int64(1)).Return(commit, nil)
gitea.EXPECT().RenameBranch("pool", "pkgB", "factory", "factory-removed").Return(nil)
common.RenameRemovedPackageBranches(context.Background(), gitea, "pool", "factory", []string{"pkgA", "pkgB"})
})
}
+5 -1
View File
@@ -390,9 +390,13 @@ func (d DevelProjects) GetDevelProject(pkg string) (string, error) {
return "", DevelProjectNotFound
}
// RemovedBranchSuffix is the suffix appended to a package's project branch when
// the package is removed (e.g. "factory" -> "factory-removed").
const RemovedBranchSuffix = "-removed"
var removedBranchNameSuffixes []string = []string{
"-rm",
"-removed",
RemovedBranchSuffix,
"-deleted",
}
+10
View File
@@ -932,6 +932,16 @@ func (prp *PRProcessor) Process(ctx context.Context, req *models.PullRequest) (e
}
PrjGitSubmoduleCheck(prp.ctx, config, git, prjPath, pkgs)
removed := make([]string, 0)
for pkg, commit := range pkgs {
if commit == "" {
removed = append(removed, pkg)
}
}
if len(removed) > 0 {
common.RenameRemovedPackageBranches(prp.ctx, Gitea.WithContext(prp.ctx), config.Organization, config.Branch, removed)
}
}
// manually merge or close entire prset that is still open
+21
View File
@@ -3,6 +3,7 @@ package main
import (
"context"
"fmt"
"os"
"path"
"runtime/debug"
"strings"
@@ -136,9 +137,29 @@ func (s *DefaultStateChecker) VerifyProjectState(ctx context.Context, config *co
return nil, fmt.Errorf("Error fetching or creating '%s' -- aborting verifyProjectState(). Err: %w", common.TaskRefBranch(prjGitOrg, prjGitRepo, prjGitBranch), err)
}
var oldHead string
if fi, errStat := os.Stat(path.Join(git.GetPath(), prjPath, ".git")); errStat == nil && fi.IsDir() {
if out, errHead := git.GitExecWithOutput(prjPath, "rev-parse", "HEAD"); errHead == nil {
oldHead = strings.TrimSpace(out)
}
}
_, err = git.GitClone(prjPath, prjGitBranch, PrjGitRepo.SSHURL)
common.PanicOnError(err)
// Detect submodule removals that happened outside the bot's PR flow (e.g. a
// direct push to the project branch) and rename the affected package branches.
if oldHead != "" {
if newHead, errHead := git.GitBranchHead(prjPath, prjGitBranch); errHead == nil && oldHead != newHead {
if removed, errDetect := common.DetectRemovedSubmodules(git, prjPath, oldHead, newHead); errDetect != nil {
common.LogErrorCtx(ctx, "Cannot detect removed submodules in", common.TaskRefBranch(prjGitOrg, prjGitRepo, prjGitBranch), ":", errDetect)
} else if len(removed) > 0 {
common.LogInfoCtx(ctx, "Project repository updated from", oldHead, "to", newHead, "- renaming removed package branches")
common.RenameRemovedPackageBranches(ctx, Gitea.WithContext(ctx), config.Organization, config.Branch, removed)
}
}
}
prsToProcess = append(prsToProcess, &PRToProcess{
Org: prjGitOrg,
Repo: prjGitRepo,