sigs.k8s.io/prow@v0.0.0-20240503223140-c5e374dc7eb1/pkg/plugins/assign/assign.go (about)

     1  /*
     2  Copyright 2017 The Kubernetes Authors.
     3  
     4  Licensed under the Apache License, Version 2.0 (the "License");
     5  you may not use this file except in compliance with the License.
     6  You may obtain a copy of the License at
     7  
     8      http://www.apache.org/licenses/LICENSE-2.0
     9  
    10  Unless required by applicable law or agreed to in writing, software
    11  distributed under the License is distributed on an "AS IS" BASIS,
    12  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    13  See the License for the specific language governing permissions and
    14  limitations under the License.
    15  */
    16  
    17  package assign
    18  
    19  import (
    20  	"fmt"
    21  	"regexp"
    22  	"strings"
    23  
    24  	"github.com/sirupsen/logrus"
    25  
    26  	"sigs.k8s.io/prow/pkg/config"
    27  	"sigs.k8s.io/prow/pkg/github"
    28  	"sigs.k8s.io/prow/pkg/pluginhelp"
    29  	"sigs.k8s.io/prow/pkg/plugins"
    30  )
    31  
    32  const pluginName = "assign"
    33  
    34  var (
    35  	assignRe = regexp.MustCompile(`(?mi)^/(un)?assign(( @?[-\w]+?)*)\s*$`)
    36  	// CCRegexp parses and validates /cc commands, also used by blunderbuss
    37  	CCRegexp = regexp.MustCompile(`(?mi)^/(un)?cc(( +@?[-/\w]+?)*)\s*$`)
    38  )
    39  
    40  func init() {
    41  	plugins.RegisterGenericCommentHandler(pluginName, handleGenericComment, helpProvider)
    42  }
    43  
    44  func helpProvider(config *plugins.Configuration, _ []config.OrgRepo) (*pluginhelp.PluginHelp, error) {
    45  	// The Config field is omitted because this plugin is not configurable.
    46  	pluginHelp := &pluginhelp.PluginHelp{
    47  		Description: "The assign plugin assigns or requests reviews from users. Specific users can be assigned with the command '/assign @user1' or have reviews requested of them with the command '/cc @user1'. If no users are specified, the commands default to targeting the user who created the command. Assignments and requested reviews can be removed in the same way that they are added by prefixing the commands with 'un'.",
    48  	}
    49  	pluginHelp.AddCommand(pluginhelp.Command{
    50  		Usage:       "/[un]assign [[@]<username>...]",
    51  		Description: "Assigns assignee(s) to the PR",
    52  		Featured:    true,
    53  		WhoCanUse:   "Anyone can use the command, but the target user(s) must be an org member, a repo collaborator, or should have previously commented on the issue or PR.",
    54  		Examples:    []string{"/assign", "/unassign", "/assign @spongebob", "/assign spongebob patrick"},
    55  	})
    56  	pluginHelp.AddCommand(pluginhelp.Command{
    57  		Usage:       "/[un]cc [[@]<username>...]",
    58  		Description: "Requests a review from the user(s).",
    59  		Featured:    true,
    60  		WhoCanUse:   "Anyone can use the command, but the target user(s) must be a member of the org that owns the repository.",
    61  		Examples:    []string{"/cc", "/uncc", "/cc @spongebob", "/cc spongebob patrick"},
    62  	})
    63  	return pluginHelp, nil
    64  }
    65  
    66  type githubClient interface {
    67  	AssignIssue(owner, repo string, number int, logins []string) error
    68  	UnassignIssue(owner, repo string, number int, logins []string) error
    69  
    70  	RequestReview(org, repo string, number int, logins []string) error
    71  	UnrequestReview(org, repo string, number int, logins []string) error
    72  
    73  	CreateComment(owner, repo string, number int, comment string) error
    74  }
    75  
    76  func handleGenericComment(pc plugins.Agent, e github.GenericCommentEvent) error {
    77  	if e.Action != github.GenericCommentActionCreated {
    78  		return nil
    79  	}
    80  	err := handle(newAssignHandler(e, pc.GitHubClient, pc.Logger))
    81  	if e.IsPR {
    82  		err = combineErrors(err, handle(newReviewHandler(e, pc.GitHubClient, pc.Logger)))
    83  	}
    84  	return err
    85  }
    86  
    87  func parseLogins(text string) []string {
    88  	var parts []string
    89  	for _, p := range strings.Split(text, " ") {
    90  		t := strings.Trim(p, "@ ")
    91  		if t == "" {
    92  			continue
    93  		}
    94  		parts = append(parts, t)
    95  	}
    96  	return parts
    97  }
    98  
    99  func combineErrors(err1, err2 error) error {
   100  	if err1 != nil && err2 != nil {
   101  		return fmt.Errorf("two errors: 1) %v 2) %w", err1, err2)
   102  	} else if err1 != nil {
   103  		return err1
   104  	} else {
   105  		return err2
   106  	}
   107  }
   108  
   109  // handle is the generic handler for the assign plugin. It uses the handler's regexp and affectedLogins
   110  // functions to identify the users to add and/or remove and then passes the appropriate users to the
   111  // handler's add and remove functions. If add fails to add some of the users, a response comment is
   112  // created where the body of the response is generated by the handler's addFailureResponse function.
   113  func handle(h *handler) error {
   114  	e := h.event
   115  	org := e.Repo.Owner.Login
   116  	repo := e.Repo.Name
   117  	matches := h.regexp.FindAllStringSubmatch(e.Body, -1)
   118  	if matches == nil {
   119  		return nil
   120  	}
   121  	users := make(map[string]bool)
   122  	for _, re := range matches {
   123  		add := re[1] != "un" // un<cmd> == !add
   124  		if re[2] == "" {
   125  			users[e.User.Login] = add
   126  		} else {
   127  			for _, login := range parseLogins(re[2]) {
   128  				users[login] = add
   129  			}
   130  		}
   131  	}
   132  	var toAdd, toRemove []string
   133  	for login, add := range users {
   134  		if add {
   135  			toAdd = append(toAdd, login)
   136  		} else {
   137  			toRemove = append(toRemove, login)
   138  		}
   139  	}
   140  
   141  	if len(toRemove) > 0 {
   142  		h.log.Printf("Removing %s from %s/%s#%d: %v", h.userType, org, repo, e.Number, toRemove)
   143  		if err := h.remove(org, repo, e.Number, toRemove); err != nil {
   144  			return err
   145  		}
   146  	}
   147  	if len(toAdd) > 0 {
   148  		h.log.Printf("Adding %s to %s/%s#%d: %v", h.userType, org, repo, e.Number, toAdd)
   149  		if err := h.add(org, repo, e.Number, toAdd); err != nil {
   150  			if mu, ok := err.(github.MissingUsers); ok {
   151  				msg := h.addFailureResponse(mu)
   152  				if len(msg) == 0 {
   153  					return nil
   154  				}
   155  				h.log.Printf("Failed to add %s to %s/%s#%d: %s", h.userType, org, repo, e.Number, mu.Error())
   156  				if err := h.gc.CreateComment(org, repo, e.Number, plugins.FormatResponseRaw(e.Body, e.HTMLURL, e.User.Login, msg)); err != nil {
   157  					return fmt.Errorf("comment err: %w", err)
   158  				}
   159  				return nil
   160  			}
   161  			return err
   162  		}
   163  	}
   164  	return nil
   165  }
   166  
   167  // handler is a struct that contains data about a github event and provides functions to help handle it.
   168  type handler struct {
   169  	// addFailureResponse generates the body of a response comment in the event that the add function fails.
   170  	addFailureResponse func(mu github.MissingUsers) string
   171  	// remove is the function that is called on the affected logins for a command prefixed with 'un'.
   172  	remove func(org, repo string, number int, users []string) error
   173  	// add is the function that is called on the affected logins for a command with no 'un' prefix.
   174  	add func(org, repo string, number int, users []string) error
   175  
   176  	// event is a pointer to the github.GenericCommentEvent struct that triggered the handler.
   177  	event *github.GenericCommentEvent
   178  	// regexp is the regular expression describing the command. It must have an optional 'un' prefix
   179  	// as the first subgroup and the arguments to the command as the second subgroup.
   180  	regexp *regexp.Regexp
   181  	// gc is the githubClient to use for creating response comments in the event of a failure.
   182  	gc githubClient
   183  
   184  	// log is a logrus.Entry used to record actions the handler takes.
   185  	log *logrus.Entry
   186  	// userType is a string that represents the type of users affected by this handler. (e.g. 'assignees')
   187  	userType string
   188  }
   189  
   190  func newAssignHandler(e github.GenericCommentEvent, gc githubClient, log *logrus.Entry) *handler {
   191  	org := e.Repo.Owner.Login
   192  	addFailureResponse := func(mu github.MissingUsers) string {
   193  		return fmt.Sprintf("GitHub didn't allow me to assign the following users: %s.\n\nNote that only [%s members](https://github.com/orgs/%s/people) with read permissions, repo collaborators and people who have commented on this issue/PR can be assigned. Additionally, issues/PRs can only have 10 assignees at the same time.\nFor more information please see [the contributor guide](https://git.k8s.io/community/contributors/guide/first-contribution.md#issue-assignment-in-github)", strings.Join(mu.Users, ", "), org, org)
   194  	}
   195  
   196  	return &handler{
   197  		addFailureResponse: addFailureResponse,
   198  		remove:             gc.UnassignIssue,
   199  		add:                gc.AssignIssue,
   200  		event:              &e,
   201  		regexp:             assignRe,
   202  		gc:                 gc,
   203  		log:                log,
   204  		userType:           "assignee(s)",
   205  	}
   206  }
   207  
   208  func newReviewHandler(e github.GenericCommentEvent, gc githubClient, log *logrus.Entry) *handler {
   209  	org := e.Repo.Owner.Login
   210  	addFailureResponse := func(mu github.MissingUsers) string {
   211  		return fmt.Sprintf("GitHub didn't allow me to request PR reviews from the following users: %s.\n\nNote that only [%s members](https://github.com/orgs/%s/people) and repo collaborators can review this PR, and authors cannot review their own PRs.", strings.Join(mu.Users, ", "), org, org)
   212  	}
   213  
   214  	return &handler{
   215  		addFailureResponse: addFailureResponse,
   216  		remove:             gc.UnrequestReview,
   217  		add:                gc.RequestReview,
   218  		event:              &e,
   219  		regexp:             CCRegexp,
   220  		gc:                 gc,
   221  		log:                log,
   222  		userType:           "reviewer(s)",
   223  	}
   224  }