Repository: kubernetes-sigs/prow

Issue:
Cherrypicker plugin does not work for private repositories
We want to use the cherry picker plugin but it is not working with private repositories since forking is disabled in our organisation and we don't want to enable it since we cannot control who can fork our private repositories.

The cherrypicker returns a 403 status code if trying to fork:
> the GitHub API request returns a 403 error: {"message":"The repository exists, but forking is disabled.","documentation_url":"[https://docs.github.com/rest/repos/forks#create-a-fork","status":"403"}](https://docs.github.com/rest/repos/forks#create-a-fork%22,%22status%22:%22403%22%7D)


I propose to let the cherrypicker push into the same repository if it get's a forbidden error from the API.

Discussion:
@petr-muller: /area plugins
/kind feature

> I propose to let the cherrypicker push into the same repository if it get's a forbidden error from the API.

Sounds good to me

Solution patch:
diff --git a/cmd/external-plugins/cherrypicker/main.go b/cmd/external-plugins/cherrypicker/main.go
index 6c533e735..1b166290d 100644
--- a/cmd/external-plugins/cherrypicker/main.go
+++ b/cmd/external-plugins/cherrypicker/main.go
@@ -121,10 +121,6 @@ func main() {
 	if err != nil {
 		logrus.WithError(err).Fatal("Error getting bot name.")
 	}
-	repos, err := githubClient.GetRepos(botUser.Login, true)
-	if err != nil {
-		log.WithError(err).Fatal("Error listing bot repositories.")
-	}
 
 	server := &Server{
 		tokenGenerator: secret.GetTokenGenerator(o.webhookSecretFile),
@@ -143,8 +139,6 @@ func main() {
 
 		bare:     &http.Client{},
 		patchURL: "https://patch-diff.githubusercontent.com",
-
-		repos: repos,
 	}
 
 	health := pjutil.NewHealthOnPort(o.instrumentationOptions.HealthPort)
diff --git a/cmd/external-plugins/cherrypicker/server.go b/cmd/external-plugins/cherrypicker/server.go
index 2eed87d9a..183caca34 100644
--- a/cmd/external-plugins/cherrypicker/server.go
+++ b/cmd/external-plugins/cherrypicker/server.go
@@ -91,9 +91,9 @@ type Server struct {
 
 	gc git.ClientFactory
 	// Used for unit testing
-	push func(forkName, newBranch string, force bool) error
-	ghc  githubClient
-	log  logrus.FieldLogger
+	pusher pusher
+	ghc    githubClient
+	log    logrus.FieldLogger
 
 	// Labels to apply to the cherrypicked PR.
 	labels []string
@@ -109,10 +109,26 @@ type Server struct {
 	bare     *http.Client
 	patchURL string
 
-	repoLock sync.Mutex
-	repos    []github.Repo
-	mapLock  sync.Mutex
-	lockMap  map[cherryPickRequest]*sync.Mutex
+	mapLock sync.Mutex
+	lockMap map[cherryPickRequest]*sync.Mutex
+}
+
+type pusher interface {
+	Push(r git.RepoClient, newBranch string, force bool) error
+}
+
+type forkPusher struct {
+	forkName string
+}
+
+func (p *forkPusher) Push(r git.RepoClient, newBranch string, force bool) error {
+	return r.PushToNamedFork(p.forkName, newBranch, force)
+}
+
+type centralPusher struct{}
+
+func (p *centralPusher) Push(r git.RepoClient, newBranch string, force bool) error {
+	return r.PushToCentral(newBranch, force)
 }
 
 type cherryPickRequest struct {
@@ -460,18 +476,22 @@ func (s *Server) handle(logger logrus.FieldLogger, requester string, comment *gi
 	lock.Lock()
 	defer lock.Unlock()
 
-	forkName, err := s.ensureForkExists(org, repo)
+	p, pushOrg, err := s.getPusherAndOrg(logger, org, repo)
 	if err != nil {
-		logger.WithError(err).Warn("failed to ensure fork exists")
-		resp := fmt.Sprintf("cannot fork %s/%s: %v", org, repo, err)
+		logger.WithError(err).Warn("failed get pusher")
+		resp := fmt.Sprintf("cannot decide how to push into %s/%s: %v", org, repo, err)
 		return s.createComment(logger, org, repo, num, comment, resp)
 	}
 
+	if s.pusher != nil {
+		p = s.pusher
+	}
+
 	// Clone the repo, checkout the target branch.
 	startClone := time.Now()
 	r, err := s.gc.ClientFor(org, repo)
 	if err != nil {
-		return fmt.Errorf("failed to get git client for %s/%s: %w", org, forkName, err)
+		return fmt.Errorf("failed to get git client for %s/%s: %w", org, repo, err)
 	}
 	defer func() {
 		if err := r.Clean(); err != nil {
@@ -550,12 +570,8 @@ func (s *Server) handle(logger logrus.FieldLogger, requester string, comment *gi
 		return utilerrors.NewAggregate(errs)
 	}
 
-	push := r.PushToNamedFork
-	if s.push != nil {
-		push = s.push
-	}
-	// Push the new branch in the bot's fork.
-	if err := push(forkName, newBranch, true); err != nil {
+	// Push the new branch
+	if err := p.Push(r, newBranch, true); err != nil {
 		logger.WithError(err).Warn("failed to push chery-picked changes to GitHub")
 		resp := fmt.Sprintf("failed to push cherry-picked changes in GitHub: %v", err)
 		return utilerrors.NewAggregate([]error{err, s.createComment(logger, org, repo, num, comment, resp)})
@@ -568,7 +584,12 @@ func (s *Server) handle(logger logrus.FieldLogger, requester string, comment *gi
 	} else {
 		cherryPickBody = cherrypicker.CreateCherrypickBody(num, "", releaseNoteFromParentPR(body), chainBranches)
 	}
-	head := fmt.Sprintf("%s:%s", s.botUser.Login, newBranch)
+
+	head := fmt.Sprintf("%s:%s", pushOrg, newBranch)
+	if pushOrg == org {
+		head = newBranch
+	}
+
 	createdNum, err := s.ghc.CreatePullRequest(org, repo, title, cherryPickBody, head, targetBranch, true)
 	if err != nil {
 		logger.WithError(err).Warn("failed to create new pull request")
@@ -644,20 +665,22 @@ func (s *Server) createIssue(l logrus.FieldLogger, org, repo, title, body string
 	return s.createComment(l, org, repo, num, comment, fmt.Sprintf("new issue created for failed cherrypick: #%d", issueNum))
 }
 
-// ensureForkExists ensures a fork of org/repo exists for the bot.
-func (s *Server) ensureForkExists(org, repo string) (string, error) {
-	fork := s.botUser.Login + "/" + repo
-
+func (s *Server) getPusherAndOrg(l logrus.FieldLogger, org, repo string) (pusher, string, error) {
 	// fork repo if it doesn't exist
-	repo, err := s.ghc.EnsureFork(s.botUser.Login, org, repo)
-	if err != nil {
-		return repo, err
+	forkName, err := s.ghc.EnsureFork(s.botUser.Login, org, repo)
+	if err != nil && !github.IsForbidden(err) {
+		return nil, "", fmt.Errorf("failed to ensure fork exists: %w", err)
 	}
 
-	s.repoLock.Lock()
-	defer s.repoLock.Unlock()
-	s.repos = append(s.repos, github.Repo{FullName: fork, Fork: true})
-	return repo, nil
+	// private repos cannot be forked because by default it's denied on org level.
+	// Push into the same org/repo as used for the cherry pick if it's forbidden
+	if github.IsForbidden(err) {
+		l.Warnf("forking repo %s/%s is forbidden, pushing cherrypick branch into the same repo", org, repo)
+		return &centralPusher{}, org, nil
+	}
+	return &forkPusher{
+		forkName: forkName,
+	}, s.botUser.Login, nil
 }
 
 // getPatch gets the patch for the provided PR and creates a local
diff --git a/pkg/crier/reporters/gcs/kubernetes/reporter.go b/pkg/crier/reporters/gcs/kubernetes/reporter.go
index b67751718..8f26e9834 100644
--- a/pkg/crier/reporters/gcs/kubernetes/reporter.go
+++ b/pkg/crier/reporters/gcs/kubernetes/reporter.go
@@ -57,10 +57,12 @@ type gcsK8sReporter struct {
 type PodReport struct {
 	Pod    *v1.Pod    `json:"pod,omitempty"`
 	Events []v1.Event `json:"events,omitempty"`
+	Node   *v1.Node   `json:"node,omitempty"`
 }
 
 type resourceGetter interface {
 	GetPod(ctx context.Context, cluster, namespace, name string) (*v1.Pod, error)
+	GetNode(ctx context.Context, cluster, name string) (*v1.Node, error)
 	GetEvents(cluster, namespace string, pod *v1.Pod) ([]v1.Event, error)
 	PatchPod(ctx context.Context, cluster, namespace, name string, pt types.PatchType, data []byte) error
 }
@@ -80,6 +82,13 @@ func (rg k8sResourceGetter) GetPod(ctx context.Context, cluster, namespace, name
 	return rg.podClientSets[cluster].Pods(namespace).Get(ctx, name, metav1.GetOptions{})
 }
 
+func (rg k8sResourceGetter) GetNode(ctx context.Context, cluster, name string) (*v1.Node, error) {
+	if _, ok := rg.podClientSets[cluster]; !ok {
+		return nil, fmt.Errorf("couldn't find cluster %q", cluster)
+	}
+	return rg.podClientSets[cluster].Nodes().Get(ctx, name, metav1.GetOptions{})
+}
+
 func (rg k8sResourceGetter) PatchPod(ctx context.Context, cluster, namespace, name string, pt types.PatchType, data []byte) error {
 	if _, ok := rg.podClientSets[cluster]; !ok {
 		return fmt.Errorf("couldn't find cluster %q", cluster)
@@ -183,11 +192,18 @@ func (gr *gcsK8sReporter) reportPodInfo(ctx context.Context, log *logrus.Entry,
 	}
 
 	var events []v1.Event
+	var node *v1.Node
 	if pod != nil {
 		events, err = gr.rg.GetEvents(pj.Spec.Cluster, gr.cfg().PodNamespace, pod)
 		if err != nil {
 			log.WithError(err).Info("Couldn't fetch events for pod")
 		}
+
+		node, err = gr.rg.GetNode(ctx, pj.Spec.Cluster, pod.Spec.NodeName)
+		if err != nil {
+			log.WithError(err).Info("Couldn't fetch node of the pod")
+			node = nil
+		}
 	}
 
 	if pod == nil && len(events) == 0 {
@@ -198,6 +214,7 @@ func (gr *gcsK8sReporter) reportPodInfo(ctx context.Context, log *logrus.Entry,
 	report := PodReport{
 		Pod:    pod,
 		Events: events,
+		Node:   node,
 	}
 
 	output, err := json.MarshalIndent(report, "", "\t")
diff --git a/pkg/git/v2/client_factory.go b/pkg/git/v2/client_factory.go
index 7245e03d5..e329cc083 100644
--- a/pkg/git/v2/client_factory.go
+++ b/pkg/git/v2/client_factory.go
@@ -200,6 +200,76 @@ func defaultTempDir() *string {
 // ClientFactoryOpts allows to manipulate the options for a ClientFactory
 type ClientFactoryOpt func(*ClientFactoryOpts)
 
+// WithHost sets the Host option.
+func WithHost(host string) ClientFactoryOpt {
+	return func(o *ClientFactoryOpts) {
+		o.Host = host
+	}
+}
+
+// WithInsecureHTTP sets the UseInsecureHTTP option.
+func WithInsecureHTTP(useHTTP bool) ClientFactoryOpt {
+	return func(o *ClientFactoryOpts) {
+		o.UseInsecureHTTP = &useHTTP
+	}
+}
+
+// WithSSH sets the UseSSH option.
+func WithSSH(useSSH bool) ClientFactoryOpt {
+	return func(o *ClientFactoryOpts) {
+		o.UseSSH = &useSSH
+	}
+}
+
+// WithCacheDirBase sets the CacheDirBase option.
+func WithCacheDirBase(dir string) ClientFactoryOpt {
+	return func(o *ClientFactoryOpts) {
+		o.CacheDirBase = &dir
+	}
+}
+
+// WithUsername sets the Username option.
+func WithUsername(getter LoginGetter) ClientFactoryOpt {
+	return func(o *ClientFactoryOpts) {
+		o.Username = getter
+	}
+}
+
+// WithToken sets the Token option.
+func WithToken(getter TokenGetter) ClientFactoryOpt {
+	return func(o *ClientFactoryOpts) {
+		o.Token = getter
+	}
+}
+
+// WithGitUser sets the GitUser option.
+func WithGitUser(getter GitUserGetter) ClientFactoryOpt {
+	return func(o *ClientFactoryOpts) {
+		o.GitUser = getter
+	}
+}
+
+// WithCensor sets the Censor option.
+func WithCensor(censor Censor) ClientFactoryOpt {
+	return func(o *ClientFactoryOpts) {
+		o.Censor = censor
+	}
+}
+
+// WithCookieFile sets the CookieFilePath option.
+func WithCookieFile(path string) ClientFactoryOpt {
+	return func(o *ClientFactoryOpts) {
+		o.CookieFilePath = path
+	}
+}
+
+// WithPersist sets the Persist option.
+func WithPersist(persist bool) ClientFactoryOpt {
+	return func(o *ClientFactoryOpts) {
+		o.Persist = &persist
+	}
+}
+
 func defaultClientFactoryOpts(cfo *ClientFactoryOpts) {
 	if cfo.Host == "" {
 		cfo.Host = "github.com"
diff --git a/pkg/github/client.go b/pkg/github/client.go
index fded6adba..9547d3f92 100644
--- a/pkg/github/client.go
+++ b/pkg/github/client.go
@@ -873,6 +873,27 @@ func IsNotFound(err error) bool {
 	return false
 }
 
+// NewForbidden returns a forbiddenError which may be useful for tests
+func NewForbidden() error {
+	return forbiddenError{}
+}
+
+type forbiddenError struct {
+	body []byte
+}
+
+func (e forbiddenError) Error() string {
+	return fmt.Sprintf("the GitHub API request returns a 403 error: %s", e.body)
+}
+
+func IsForbidden(err error) bool {
+	if err == nil {
+		return false
+	}
+	var forbiddenErr forbiddenError
+	return errors.As(err, &forbiddenErr)
+}
+
 // Make a request with retries. If ret is not nil, unmarshal the response body
 // into it. Returns an error if the exit code is not one of the provided codes.
 func (c *client) request(r *request, ret interface{}) (int, error) {
@@ -1019,7 +1040,7 @@ func (c *client) requestRetryWithContext(ctx context.Context, method, path, acce
 						err = fmt.Errorf("the account is using %s oauth scopes, please make sure you are using at least one of the following oauth scopes: %s", authorizedScopes, acceptedScopes)
 					} else {
 						body, _ := io.ReadAll(resp.Body)
-						err = fmt.Errorf("the GitHub API request returns a 403 error: %s", string(body))
+						err = forbiddenError{body: body}
 					}
 					resp.Body.Close()
 					break
diff --git a/pkg/plugins/config.go b/pkg/plugins/config.go
index 5279566d9..493ca36cb 100644
--- a/pkg/plugins/config.go
+++ b/pkg/plugins/config.go
@@ -427,7 +427,34 @@ func (l Label) RestrictedLabelsFor(org, repo string) map[string]RestrictedLabel
 	result := map[string]RestrictedLabel{}
 	for _, orgRepoKey := range []string{"*", org, org + "/" + repo} {
 		for _, restrictedLabel := range l.RestrictedLabels[orgRepoKey] {
-			result[strings.ToLower(restrictedLabel.Label)] = restrictedLabel
+			labelKey := strings.ToLower(restrictedLabel.Label)
+			existing, exists := result[labelKey]
+			if !exists {
+				result[labelKey] = restrictedLabel
+			} else {
+				merged := existing
+				// Merge allowed users
+				allUsers := sets.New(existing.AllowedUsers...)
+				allUsers.Insert(restrictedLabel.AllowedUsers...)
+				merged.AllowedUsers = sets.List(allUsers)
+				// Merge allowed teams
+				allTeams := sets.New(existing.AllowedTeams...)
+				allTeams.Insert(restrictedLabel.AllowedTeams...)
+				merged.AllowedTeams = sets.List(allTeams)
+				// Merge assign_on
+				assignOnMap := make(map[string]AssignOnLabel)
+				for _, ao := range existing.AssignOn {
+					assignOnMap[ao.Label] = ao
+				}
+				for _, ao := range restrictedLabel.AssignOn {
+					assignOnMap[ao.Label] = ao
+				}
+				merged.AssignOn = make([]AssignOnLabel, 0, len(assignOnMap))
+				for _, ao := range assignOnMap {
+					merged.AssignOn = append(merged.AssignOn, ao)
+				}
+				result[labelKey] = merged
+			}
 		}
 	}
 



Test patch:
diff --git a/cmd/external-plugins/cherrypicker/server_test.go b/cmd/external-plugins/cherrypicker/server_test.go
index c15322bda..02d615b3c 100644
--- a/cmd/external-plugins/cherrypicker/server_test.go
+++ b/cmd/external-plugins/cherrypicker/server_test.go
@@ -25,9 +25,11 @@ import (
 
 	"github.com/google/go-cmp/cmp"
 	"github.com/sirupsen/logrus"
+	"github.com/stretchr/testify/assert"
 	"k8s.io/apimachinery/pkg/util/sets"
 
 	"sigs.k8s.io/prow/pkg/git/localgit"
+	"sigs.k8s.io/prow/pkg/git/v2"
 	v2 "sigs.k8s.io/prow/pkg/git/v2"
 	"sigs.k8s.io/prow/pkg/github"
 )
@@ -129,6 +131,9 @@ func (f *fghc) EnsureFork(forkingUser, org, repo string) (string, error) {
 	if repo == "error" {
 		return repo, errors.New("errors")
 	}
+	if repo == "forbidden" {
+		return repo, github.NewForbidden()
+	}
 	return repo, nil
 }
 
@@ -221,6 +226,12 @@ func (f *fghc) CreateFork(org, repo string) (string, error) {
 	return repo, nil
 }
 
+type fakePusher struct{}
+
+func (f fakePusher) Push(r git.RepoClient, newBranch string, force bool) error {
+	return nil
+}
+
 var initialFiles = map[string][]byte{
 	"bar.go": []byte(`// Package bar does an interesting thing.
 package bar
@@ -340,11 +351,10 @@ func testCherryPickIC(clients localgit.Clients, t *testing.T) {
 	s := &Server{
 		botUser:        botUser,
 		gc:             c,
-		push:           func(forkName, newBranch string, force bool) error { return nil },
+		pusher:         fakePusher{},
 		ghc:            ghc,
 		tokenGenerator: getSecret,
 		log:            logrus.StandardLogger().WithField("client", "cherrypicker"),
-		repos:          []github.Repo{{Fork: true, FullName: "ci-robot/bar"}},
 
 		prowAssignments: true,
 	}
@@ -482,11 +492,10 @@ func testCherryPickPR(clients localgit.Clients, t *testing.T) {
 	s := &Server{
 		botUser:        botUser,
 		gc:             c,
-		push:           func(forkName, newBranch string, force bool) error { return nil },
+		pusher:         fakePusher{},
 		ghc:            ghc,
 		tokenGenerator: getSecret,
 		log:            logrus.StandardLogger().WithField("client", "cherrypicker"),
-		repos:          []github.Repo{{Fork: true, FullName: "ci-robot/bar"}},
 
 		prowAssignments: false,
 	}
@@ -495,7 +504,7 @@ func testCherryPickPR(clients localgit.Clients, t *testing.T) {
 		t.Fatalf("unexpected error: %v", err)
 	}
 
-	var expectedFn = func(branch string) string {
+	expectedFn := func(branch string) string {
 		expectedTitle := fmt.Sprintf("[%s] This is a fix for Y", branch)
 		expectedBody := fmt.Sprintf("This is an automated cherry-pick of #%d", prNumber)
 		if branch == "release-1.3" {
@@ -618,10 +627,9 @@ func testCherryPickPRAfterMerge(clients localgit.Clients, t *testing.T) {
 		gc:             c,
 		ghc:            ghc,
 		botUser:        &github.UserData{Login: "ci-robot", Email: "ci-robot@users.noreply.github.com"},
-		push:           func(forkName, newBranch string, force bool) error { return nil },
+		pusher:         fakePusher{},
 		tokenGenerator: func() []byte { return []byte("sha=abcdefg") },
 		log:            logger.WithField("client", "cherrypicker"),
-		repos:          []github.Repo{{Fork: true, FullName: "ci-robot/" + githubRepo}},
 
 		prowAssignments: false,
 	}
@@ -709,11 +717,10 @@ func testCherryPickOfCherryPickPR(clients localgit.Clients, t *testing.T) {
 	s := &Server{
 		botUser:        botUser,
 		gc:             c,
-		push:           func(forkName, newBranch string, force bool) error { return nil },
+		pusher:         fakePusher{},
 		ghc:            ghc,
 		tokenGenerator: getSecret,
 		log:            logrus.StandardLogger().WithField("client", "cherrypicker"),
-		repos:          []github.Repo{{Fork: true, FullName: "ci-robot/bar"}},
 
 		prowAssignments: false,
 	}
@@ -739,7 +746,7 @@ func testCherryPickOfCherryPickPR(clients localgit.Clients, t *testing.T) {
 		t.Fatalf("unexpected error: %v", err)
 	}
 
-	var expectedFn = func(branch string) string {
+	expectedFn := func(branch string) string {
 		expectedTitle := fmt.Sprintf("[%s] This is a fix for Y", branch)
 		expectedBody := fmt.Sprintf("This is an automated cherry-pick of #%d", prNumber)
 		expectedHead := fmt.Sprintf(botUser.Login+":"+cherryPickBranchFmt, prNumber, branch)
@@ -896,11 +903,10 @@ func testCherryPickPRWithLabels(clients localgit.Clients, t *testing.T) {
 					s := &Server{
 						botUser:        botUser,
 						gc:             c,
-						push:           func(forkName, newBranch string, force bool) error { return nil },
+						pusher:         fakePusher{},
 						ghc:            ghc,
 						tokenGenerator: getSecret,
 						log:            logrus.StandardLogger().WithField("client", "cherrypicker"),
-						repos:          []github.Repo{{Fork: true, FullName: "ci-robot/bar"}},
 
 						labels:          []string{"cla: yes"},
 						prowAssignments: false,
@@ -1104,11 +1110,10 @@ func testCherryPickPRAssignments(clients localgit.Clients, t *testing.T) {
 		s := &Server{
 			botUser:        botUser,
 			gc:             c,
-			push:           func(forkName, newBranch string, force bool) error { return nil },
+			pusher:         fakePusher{},
 			ghc:            ghc,
 			tokenGenerator: getSecret,
 			log:            logrus.StandardLogger().WithField("client", "cherrypicker"),
-			repos:          []github.Repo{{Fork: true, FullName: "ci-robot/bar"}},
 
 			prowAssignments: prowAssignments,
 		}
@@ -1162,61 +1167,73 @@ func TestHandleLocks(t *testing.T) {
 	}
 }
 
-func TestEnsureForkExists(t *testing.T) {
+func TestGetPusherAndOrg(t *testing.T) {
 	botUser := &github.UserData{Login: "ci-robot", Email: "ci-robot@users.noreply.github.com"}
 
+	l := logrus.WithField("test", t.Name())
 	ghc := &fghc{}
 
 	s := &Server{
 		botUser: botUser,
 		ghc:     ghc,
-		repos:   []github.Repo{{Fork: true, FullName: "ci-robot/bar"}},
 	}
 
 	testCases := []struct {
-		name     string
-		org      string
-		repo     string
-		expected string
-		errors   bool
+		name        string
+		org         string
+		repo        string
+		expected    pusher
+		expectedOrg string
+		errors      bool
 	}{
 		{
-			name:     "Repo name does not change after ensured",
-			org:      "whatever",
-			repo:     "repo",
-			expected: "repo",
-			errors:   false,
+			name: "Repo name does not change after ensured",
+			org:  "whatever",
+			repo: "repo",
+			expected: &forkPusher{
+				forkName: "repo",
+			},
+			expectedOrg: "ci-robot",
+			errors:      false,
+		},
+		{
+			name: "EnsureFork changes repo name",
+			org:  "whatever",
+			repo: "changeme",
+			expected: &forkPusher{
+				forkName: "changed",
+			},
+			expectedOrg: "ci-robot",
+			errors:      false,
 		},
 		{
-			name:     "EnsureFork changes repo name",
-			org:      "whatever",
-			repo:     "changeme",
-			expected: "changed",
-			errors:   false,
+			name:   "EnsureFork errors",
+			org:    "whatever",
+			repo:   "error",
+			errors: true,
 		},
 		{
-			name:     "EnsureFork errors",
-			org:      "whatever",
-			repo:     "error",
-			expected: "error",
-			errors:   true,
+			name:        "forking is forbidden",
+			org:         "whatever",
+			repo:        "forbidden",
+			expected:    &centralPusher{},
+			expectedOrg: "whatever",
+			errors:      false,
 		},
 	}
 	for _, tc := range testCases {
 		t.Run(tc.name, func(t *testing.T) {
-			res, err := s.ensureForkExists(tc.org, tc.repo)
+			res, pushOrg, err := s.getPusherAndOrg(l, tc.org, tc.repo)
 			if tc.errors && err == nil {
 				t.Errorf("expected error, but did not get one")
 			}
 			if !tc.errors && err != nil {
 				t.Errorf("expected no error, but got one")
 			}
-			if res != tc.expected {
-				t.Errorf("expected %s but got %s", tc.expected, res)
-			}
+			assert.Equal(t, tc.expected, res)
+			assert.Equal(t, tc.expectedOrg, pushOrg)
 		})
 	}
-
 }
 
 type threadUnsafeFGHC struct {
diff --git a/pkg/crier/reporters/gcs/kubernetes/reporter_test.go b/pkg/crier/reporters/gcs/kubernetes/reporter_test.go
index 6bdcf3e32..ffa57f83f 100644
--- a/pkg/crier/reporters/gcs/kubernetes/reporter_test.go
+++ b/pkg/crier/reporters/gcs/kubernetes/reporter_test.go
@@ -136,6 +136,7 @@ type testResourceGetter struct {
 	cluster   string
 	pod       *v1.Pod
 	events    []v1.Event
+	node      *v1.Node
 	patchData string
 	patchType types.PatchType
 	patchErr  error
@@ -157,6 +158,19 @@ func (rg testResourceGetter) GetPod(_ context.Context, cluster, namespace, name
 	return rg.pod, nil
 }
 
+func (rg testResourceGetter) GetNode(_ context.Context, cluster, name string) (*v1.Node, error) {
+	if rg.cluster != cluster {
+		return nil, fmt.Errorf("expected cluster %q but got cluster %q", rg.cluster, cluster)
+	}
+	if rg.node == nil {
+		return nil, errors.New("no such node")
+	}
+	if rg.node.ObjectMeta.Name != name {
+		return nil, fmt.Errorf("expected name %q, but got name %q", rg.node.ObjectMeta.Name, name)
+	}
+	return rg.node, nil
+}
+
 func (rg testResourceGetter) GetEvents(cluster, namespace string, pod *v1.Pod) ([]v1.Event, error) {
 	if rg.cluster != cluster {
 		return nil, fmt.Errorf("expected cluster %q but got cluster %q", rg.cluster, cluster)
@@ -200,6 +214,7 @@ func TestReportPodInfo(t *testing.T) {
 		pod                     *v1.Pod
 		patchErr                error
 		events                  []v1.Event
+		node                    *v1.Node
 		dryRun                  bool
 		expectReport            bool
 		expectErr               bool
@@ -216,6 +231,9 @@ func TestReportPodInfo(t *testing.T) {
 					Namespace: "test-pods",
 					Labels:    map[string]string{"created-by-prow": "true"},
 				},
+				Spec: v1.PodSpec{
+					NodeName: "test-node",
+				},
 			},
 			events: []v1.Event{
 				{
@@ -223,6 +241,11 @@ func TestReportPodInfo(t *testing.T) {
 					Message: "Some event",
 				},
 			},
+			node: &v1.Node{
+				ObjectMeta: metav1.ObjectMeta{
+					Name: "test-node",
+				},
+			},
 			expectReport: true,
 		},
 		{
@@ -399,6 +422,7 @@ func TestReportPodInfo(t *testing.T) {
 				cluster:   "the-build-cluster",
 				pod:       tc.pod,
 				events:    tc.events,
+				node:      tc.node,
 				patchErr:  tc.patchErr,
 				patchData: tc.expectedPatch,
 				patchType: types.MergePatchType,
@@ -449,6 +473,9 @@ func TestReportPodInfo(t *testing.T) {
 			if !cmp.Equal(result.Events, tc.events) {
 				t.Errorf("Got mismatching events:\n%s", cmp.Diff(tc.events, result.Events))
 			}
+			if !cmp.Equal(result.Node, tc.node) {
+				t.Errorf("Got mismatching node:\n%s", cmp.Diff(tc.node, result.Node))
+			}
 		})
 	}
 }
diff --git a/pkg/plugins/config_test.go b/pkg/plugins/config_test.go
index 876560d32..b039ff823 100644
--- a/pkg/plugins/config_test.go
+++ b/pkg/plugins/config_test.go
@@ -25,6 +25,7 @@ import (
 	"time"
 
 	"github.com/google/go-cmp/cmp"
+	"github.com/google/go-cmp/cmp/cmpopts"
 	fuzz "github.com/google/gofuzz"
 
 	apiequality "k8s.io/apimachinery/pkg/api/equality"
@@ -2416,3 +2417,174 @@ func TestValidatePluginsDupes(t *testing.T) {
 		})
 	}
 }
+
+func TestRestrictedLabelsFor(t *testing.T) {
+	tests := []struct {
+		name     string
+		config   Label
+		org      string
+		repo     string
+		expected map[string]RestrictedLabel
+	}{
+		{
+			name: "simple case - no overrides",
+			config: Label{
+				RestrictedLabels: map[string][]RestrictedLabel{
+					"*": {
+						{
+							Label:        "global-label",
+							AllowedTeams: []string{"team1"},
+							AllowedUsers: []string{"user1"},
+						},
+					},
+				},
+			},
+			org:  "testorg",
+			repo: "testrepo",
+			expected: map[string]RestrictedLabel{
+				"global-label": {
+					Label:        "global-label",
+					AllowedTeams: []string{"team1"},
+					AllowedUsers: []string{"user1"},
+				},
+			},
+		},
+		{
+			name: "org-level override merges with global",
+			config: Label{
+				RestrictedLabels: map[string][]RestrictedLabel{
+					"*": {
+						{
+							Label:        "backport-risk-assessed",
+							AllowedTeams: []string{"global-team1", "global-team2"},
+							AllowedUsers: []string{"global-user1"},
+						},
+					},
+					"testorg": {
+						{
+							Label:        "backport-risk-assessed",
+							AllowedTeams: []string{"org-team1"},
+							AllowedUsers: []string{"org-user1", "org-user2"},
+						},
+					},
+				},
+			},
+			org:  "testorg",
+			repo: "testrepo",
+			expected: map[string]RestrictedLabel{
+				"backport-risk-assessed": {
+					Label:        "backport-risk-assessed",
+					AllowedTeams: []string{"global-team1", "global-team2", "org-team1"},
+					AllowedUsers: []string{"global-user1", "org-user1", "org-user2"},
+				},
+			},
+		},
+		{
+			name: "org config with only users preserves global teams",
+			config: Label{
+				RestrictedLabels: map[string][]RestrictedLabel{
+					"*": {
+						{
+							Label:        "backport-risk-assessed",
+							AllowedTeams: []string{"openshift-patch-managers", "openshift-staff-engineers"},
+						},
+					},
+					"openshift": {
+						{
+							Label:        "backport-risk-assessed",
+							AllowedUsers: []string{"user1", "user2"}, // No teams specified - the bug was here
+						},
+					},
+				},
+			},
+			org:  "openshift",
+			repo: "api",
+			expected: map[string]RestrictedLabel{
+				"backport-risk-assessed": {
+					Label:        "backport-risk-assessed",
+					AllowedTeams: []string{"openshift-patch-managers", "openshift-staff-engineers"}, // Should preserve global teams
+					AllowedUsers: []string{"user1", "user2"},
+				},
+			},
+		},
+		{
+			name: "deduplication works correctly",
+			config: Label{
+				RestrictedLabels: map[string][]RestrictedLabel{
+					"*": {
+						{
+							Label:        "test-label",
+							AllowedTeams: []string{"team1", "team2"},
+							AllowedUsers: []string{"user1", "user2"},
+							AssignOn:     []AssignOnLabel{{Label: "trigger1"}},
+						},
+					},
+					"testorg": {
+						{
+							Label:        "test-label",
+							AllowedTeams: []string{"team2", "team3"},           // team2 is duplicate
+							AllowedUsers: []string{"user2", "user3"},           // user2 is duplicate
+							AssignOn:     []AssignOnLabel{{Label: "trigger1"}}, // trigger1 is duplicate
+						},
+					},
+				},
+			},
+			org:  "testorg",
+			repo: "testrepo",
+			expected: map[string]RestrictedLabel{
+				"test-label": {
+					Label:        "test-label",
+					AllowedTeams: []string{"team1", "team2", "team3"},
+					AllowedUsers: []string{"user1", "user2", "user3"},
+					AssignOn:     []AssignOnLabel{{Label: "trigger1"}},
+				},
+			},
+		},
+	}
+
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			result := tc.config.RestrictedLabelsFor(tc.org, tc.repo)
+
+			if len(result) != len(tc.expected) {
+				t.Errorf("Expected %d labels, got %d", len(tc.expected), len(result))
+			}
+
+			for labelKey, expectedLabel := range tc.expected {
+				actualLabel, found := result[labelKey]
+				if !found {
+					t.Errorf("Expected label %s not found", labelKey)
+					continue
+				}
+
+				if actualLabel.Label != expectedLabel.Label {
+					t.Errorf("Label name mismatch for %s: expected %s, got %s", labelKey, expectedLabel.Label, actualLabel.Label)
+				}
+
+				expectedTeams := sets.List(sets.New(expectedLabel.AllowedTeams...))
+				actualTeams := sets.List(sets.New(actualLabel.AllowedTeams...))
+				if !cmp.Equal(expectedTeams, actualTeams, cmpopts.SortSlices(func(a, b string) bool { return a < b })) {
+					t.Errorf("Teams mismatch for %s:\nexpected: %v\ngot: %v", labelKey, expectedTeams, actualTeams)
+				}
+
+				expectedUsers := sets.List(sets.New(expectedLabel.AllowedUsers...))
+				actualUsers := sets.List(sets.New(actualLabel.AllowedUsers...))
+				if !cmp.Equal(expectedUsers, actualUsers, cmpopts.SortSlices(func(a, b string) bool { return a < b })) {
+					t.Errorf("Users mismatch for %s:\nexpected: %v\ngot: %v", labelKey, expectedUsers, actualUsers)
+				}
+
+				expectedTriggers := sets.New[string]()
+				for _, ao := range expectedLabel.AssignOn {
+					expectedTriggers.Insert(ao.Label)
+				}
+				actualTriggers := sets.New[string]()
+				for _, ao := range actualLabel.AssignOn {
+					actualTriggers.Insert(ao.Label)
+				}
+				if !expectedTriggers.Equal(actualTriggers) {
+					t.Errorf("AssignOn triggers mismatch for %s:\nexpected: %v\ngot: %v", labelKey, sets.List(expectedTriggers), sets.List(actualTriggers))
+				}
+			}
+		})
+	}
+}