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

     1  /*
     2  Copyright 2019 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 project implements the `/project` command which allows members of the project
    18  // maintainers team to specify a project to be applied to an Issue or PR.
    19  package project
    20  
    21  import (
    22  	"fmt"
    23  	"regexp"
    24  	"sort"
    25  	"strings"
    26  
    27  	"github.com/sirupsen/logrus"
    28  
    29  	"sigs.k8s.io/prow/pkg/config"
    30  	"sigs.k8s.io/prow/pkg/github"
    31  	"sigs.k8s.io/prow/pkg/pluginhelp"
    32  	"sigs.k8s.io/prow/pkg/plugins"
    33  )
    34  
    35  const (
    36  	pluginName = "project"
    37  )
    38  
    39  var (
    40  	projectRegex              = regexp.MustCompile(`(?m)^/project\s(.*?)$`)
    41  	notTeamConfigMsg          = "There is no maintainer team for this repo or org."
    42  	notATeamMemberMsg         = "You must be a member of the [%s/%s](https://github.com/orgs/%s/teams/%s/members) github team to set the project and column."
    43  	invalidProject            = "The provided project is not valid for this organization. Projects in Kubernetes orgs and repositories: [%s]."
    44  	invalidColumn             = "A column is not provided or it's not valid for the project %s. Please provide one of the following columns in the command:\n%v"
    45  	invalidNumArgs            = "Please provide 1 or more arguments. Example usage: /project 0.5.0, /project 0.5.0 To do, /project clear 0.4.0"
    46  	projectTeamMsg            = "The project maintainers team is the github team with ID: %d."
    47  	columnsMsg                = "An issue/PR with unspecified column will be added to one of the following columns: %v."
    48  	successMovingCardMsg      = "You have successfully moved the project card for this issue to column %s (ID %d)."
    49  	successCreatingCardMsg    = "You have successfully created a project card for this issue. It's been added to project %s column %s (ID %D)."
    50  	successClearingProjectMsg = "You have successfully removed this issue/PR from project %s."
    51  	failedClearingProjectMsg  = "The project %q is not valid for the issue/PR %v. Please provide a valid project to which this issue belongs."
    52  	clearKeyword              = "clear"
    53  	projectNameToIDMap        = make(map[string]int)
    54  )
    55  
    56  type githubClient interface {
    57  	BotUserChecker() (func(candidate string) bool, error)
    58  	CreateComment(owner, repo string, number int, comment string) error
    59  	ListTeamMembers(org string, id int, role string) ([]github.TeamMember, error)
    60  	GetRepos(org string, isUser bool) ([]github.Repo, error)
    61  	GetRepoProjects(owner, repo string) ([]github.Project, error)
    62  	GetOrgProjects(org string) ([]github.Project, error)
    63  	GetProjectColumns(org string, projectID int) ([]github.ProjectColumn, error)
    64  	CreateProjectCard(org string, columnID int, projectCard github.ProjectCard) (*github.ProjectCard, error)
    65  	GetColumnProjectCard(org string, columnID int, contentURL string) (*github.ProjectCard, error)
    66  	MoveProjectCard(org string, projectCardID int, newColumnID int) error
    67  	DeleteProjectCard(org string, projectCardID int) error
    68  	TeamHasMember(org string, teamID int, memberLogin string) (bool, error)
    69  }
    70  
    71  func init() {
    72  	plugins.RegisterGenericCommentHandler(pluginName, handleGenericComment, helpProvider)
    73  }
    74  
    75  func helpProvider(config *plugins.Configuration, enabledRepos []config.OrgRepo) (*pluginhelp.PluginHelp, error) {
    76  	projectConfig := config.Project
    77  	configInfo := map[string]string{}
    78  	for _, repo := range enabledRepos {
    79  		if maintainerTeamID := projectConfig.GetMaintainerTeam(repo.Org, repo.Repo); maintainerTeamID != -1 {
    80  			configInfo[repo.String()] = fmt.Sprintf(projectTeamMsg, maintainerTeamID)
    81  		} else {
    82  			configInfo[repo.String()] = "There are no maintainer team specified for this repo or its org."
    83  		}
    84  
    85  		if columnMap := projectConfig.GetColumnMap(repo.Org, repo.Repo); len(columnMap) != 0 {
    86  			configInfo[repo.String()] = fmt.Sprintf(columnsMsg, columnMap)
    87  		}
    88  	}
    89  	yamlSnippet, err := plugins.CommentMap.GenYaml(&plugins.Configuration{
    90  		Project: plugins.ProjectConfig{
    91  			Orgs: map[string]plugins.ProjectOrgConfig{
    92  				"org": {
    93  					MaintainerTeamID: 123456,
    94  					ProjectColumnMap: map[string]string{
    95  						"project1": "To do",
    96  						"project2": "Backlog",
    97  					},
    98  					Repos: map[string]plugins.ProjectRepoConfig{
    99  						"repo": {
   100  							MaintainerTeamID: 123456,
   101  							ProjectColumnMap: map[string]string{
   102  								"project3": "To do",
   103  								"project4": "Backlog",
   104  							},
   105  						},
   106  					},
   107  				},
   108  			},
   109  		},
   110  	})
   111  	if err != nil {
   112  		logrus.WithError(err).Warnf("cannot generate comments for %s plugin", pluginName)
   113  	}
   114  	pluginHelp := &pluginhelp.PluginHelp{
   115  		Description: "The project plugin allows members of a GitHub team to set the project and column on an issue or pull request.",
   116  		Config:      configInfo,
   117  		Snippet:     yamlSnippet,
   118  	}
   119  	pluginHelp.AddCommand(pluginhelp.Command{
   120  		Usage:       "/project <board>, /project <board> <column>, or /project clear <board>",
   121  		Description: "Add an issue or PR to a project board and column",
   122  		Featured:    false,
   123  		WhoCanUse:   "Members of the project maintainer GitHub team can use the '/project' command.",
   124  		Examples:    []string{"/project 0.5.0", "/project 0.5.0 To do", "/project clear 0.4.0"},
   125  	})
   126  	return pluginHelp, nil
   127  }
   128  
   129  func handleGenericComment(pc plugins.Agent, e github.GenericCommentEvent) error {
   130  	return handle(pc.GitHubClient, pc.Logger, &e, pc.PluginConfig.Project)
   131  }
   132  
   133  func updateProjectNameToIDMap(projects []github.Project) {
   134  	for _, project := range projects {
   135  		projectNameToIDMap[project.Name] = project.ID
   136  	}
   137  }
   138  
   139  // processCommand processes the user command regex matches and returns the proposed project name,
   140  // proposed column name, whether the command is to remove issue/PR from project,
   141  // and the error message
   142  func processCommand(match string) (string, string, bool, string) {
   143  	proposedProject := ""
   144  	proposedColumnName := ""
   145  
   146  	var shouldClear = false
   147  	content := strings.TrimSpace(match)
   148  
   149  	// Take care of clear
   150  	if strings.HasPrefix(content, clearKeyword) {
   151  		shouldClear = true
   152  		content = strings.TrimSpace(strings.Replace(content, clearKeyword, "", 1))
   153  	}
   154  
   155  	// Normalize " to ' for easier handle
   156  	content = strings.ReplaceAll(content, "\"", "'")
   157  	var parts []string
   158  	if strings.Contains(content, "'") {
   159  		parts = strings.Split(content, "'")
   160  	} else { // Split by space
   161  		parts = strings.SplitN(content, " ", 2)
   162  	}
   163  
   164  	var validParts []string
   165  	for _, part := range parts {
   166  		if strings.TrimSpace(part) != "" {
   167  			validParts = append(validParts, strings.TrimSpace(part))
   168  		}
   169  	}
   170  	if len(validParts) == 0 || len(validParts) > 2 {
   171  		msg := invalidNumArgs
   172  		return "", "", false, msg
   173  	}
   174  
   175  	proposedProject = validParts[0]
   176  	if len(validParts) > 1 {
   177  		proposedColumnName = validParts[1]
   178  	}
   179  
   180  	return proposedProject, proposedColumnName, shouldClear, ""
   181  }
   182  
   183  func handle(gc githubClient, log *logrus.Entry, e *github.GenericCommentEvent, projectConfig plugins.ProjectConfig) error {
   184  	// Only handle new comments
   185  	if e.Action != github.GenericCommentActionCreated {
   186  		return nil
   187  	}
   188  
   189  	// Only handle comments that don't come from the bot
   190  	botUserChecker, err := gc.BotUserChecker()
   191  	if err != nil {
   192  		return err
   193  	}
   194  	if botUserChecker(e.User.Login) {
   195  		return nil
   196  	}
   197  
   198  	// Only handle comments that match the regex
   199  	matches := projectRegex.FindStringSubmatch(e.Body)
   200  	if len(matches) == 0 {
   201  		return nil
   202  	}
   203  
   204  	org := e.Repo.Owner.Login
   205  	repo := e.Repo.Name
   206  	proposedProject, proposedColumnName, shouldClear, msg := processCommand(matches[1])
   207  	if proposedProject == "" {
   208  		return gc.CreateComment(org, repo, e.Number, plugins.FormatResponseRaw(e.Body, e.HTMLURL, e.User.Login, msg))
   209  	}
   210  
   211  	maintainerTeamID := projectConfig.GetMaintainerTeam(org, repo)
   212  	if maintainerTeamID == -1 {
   213  		return gc.CreateComment(org, repo, e.Number, plugins.FormatResponseRaw(e.Body, e.HTMLURL, e.User.Login, notTeamConfigMsg))
   214  	}
   215  	isAMember, err := gc.TeamHasMember(org, maintainerTeamID, e.User.Login)
   216  	if err != nil {
   217  		return err
   218  	}
   219  	if !isAMember {
   220  		// not in the project maintainers team
   221  		msg = fmt.Sprintf(notATeamMemberMsg, org, repo, org, repo)
   222  		return gc.CreateComment(org, repo, e.Number, plugins.FormatResponseRaw(e.Body, e.HTMLURL, e.User.Login, msg))
   223  	}
   224  
   225  	var projects []github.Project
   226  
   227  	// see if the project in the same repo as the issue/pr
   228  	repoProjects, err := gc.GetRepoProjects(org, repo)
   229  	if err == nil {
   230  		projects = append(projects, repoProjects...)
   231  	}
   232  	updateProjectNameToIDMap(projects)
   233  
   234  	var ok bool
   235  	// Only fetch the other repos in the org if we did not find the project in the same repo as the issue/pr
   236  	if _, ok = projectNameToIDMap[proposedProject]; !ok {
   237  		repos, err := gc.GetRepos(org, false)
   238  		if err != nil {
   239  			return err
   240  		}
   241  		// Get all projects for all repos
   242  		for _, repo := range repos {
   243  			repoProjects, err := gc.GetRepoProjects(org, repo.Name)
   244  			if err != nil {
   245  				return err
   246  			}
   247  			projects = append(projects, repoProjects...)
   248  		}
   249  	}
   250  	// Only fetch org projects if we can't find the proposed project / project to clear in the repo projects
   251  	updateProjectNameToIDMap(projects)
   252  
   253  	var projectID int
   254  	if projectID, ok = projectNameToIDMap[proposedProject]; !ok {
   255  		// Get all projects for this org
   256  		orgProjects, err := gc.GetOrgProjects(org)
   257  		if err != nil {
   258  			return err
   259  		}
   260  		projects = append(projects, orgProjects...)
   261  
   262  		// If still can't find proposed project / project to clear in the list of projects, abort and create a comment
   263  		updateProjectNameToIDMap(projects)
   264  		if projectID, ok = projectNameToIDMap[proposedProject]; !ok {
   265  			slice := make([]string, 0, len(projectNameToIDMap))
   266  			for k := range projectNameToIDMap {
   267  				slice = append(slice, fmt.Sprintf("`%s`", k))
   268  			}
   269  			sort.Strings(slice)
   270  
   271  			msg = fmt.Sprintf(invalidProject, strings.Join(slice, ", "))
   272  			return gc.CreateComment(org, repo, e.Number, plugins.FormatResponseRaw(e.Body, e.HTMLURL, e.User.Login, msg))
   273  		}
   274  	}
   275  
   276  	// Get all columns for proposedProject
   277  	projectColumns, err := gc.GetProjectColumns(org, projectID)
   278  	if err != nil {
   279  		return err
   280  	}
   281  
   282  	// If proposedColumnName is not found (or not provided), add to one of the default
   283  	// columns. If none of the default columns exists, an error will be shown to the user
   284  	columnFound := false
   285  	proposedColumnID := 0
   286  	for _, c := range projectColumns {
   287  		if c.Name == proposedColumnName {
   288  			columnFound = true
   289  			proposedColumnID = c.ID
   290  			break
   291  		}
   292  	}
   293  	if !columnFound && !shouldClear {
   294  		// If user does not provide a column name, look for the columns
   295  		// specified in the project config and see if any of them exists on the
   296  		// proposed project
   297  		if proposedColumnName == "" {
   298  			defaultColumn, exists := projectConfig.GetColumnMap(org, repo)[proposedProject]
   299  			if !exists {
   300  				// Try to find the proposedProject in the org config in case the
   301  				// project is on the org level
   302  				defaultColumn, exists = projectConfig.GetOrgColumnMap(org)[proposedProject]
   303  			}
   304  			if exists {
   305  				// See if the default column exists in the actual list of project columns
   306  				for _, pc := range projectColumns {
   307  					if pc.Name == defaultColumn {
   308  						proposedColumnID = pc.ID
   309  						proposedColumnName = pc.Name
   310  						columnFound = true
   311  						break
   312  					}
   313  				}
   314  			}
   315  		}
   316  		// In this case, user does not provide the column name in the command,
   317  		// or the provided column name cannot be found, and none of the default
   318  		// columns are available in the proposed project. An error will be
   319  		// shown to the user
   320  		if !columnFound {
   321  			projectColumnNames := []string{}
   322  			for _, c := range projectColumns {
   323  				projectColumnNames = append(projectColumnNames, c.Name)
   324  			}
   325  			msg = fmt.Sprintf(invalidColumn, proposedProject, projectColumnNames)
   326  			return gc.CreateComment(org, repo, e.Number, plugins.FormatResponseRaw(e.Body, e.HTMLURL, e.User.Login, msg))
   327  		}
   328  	}
   329  
   330  	// Move this issue/PR to the new column if there's already a project card for
   331  	// this issue/PR in this project
   332  	var existingProjectCard *github.ProjectCard
   333  	var foundColumnID int
   334  	for _, colID := range projectColumns {
   335  		// make issue URL in the form of card content URL
   336  		issueURL := fmt.Sprintf("https://api.github.com/repos/%s/%s/issues/%v", org, repo, e.Number)
   337  		existingProjectCard, err = gc.GetColumnProjectCard(org, colID.ID, issueURL)
   338  		if err != nil {
   339  			return err
   340  		}
   341  
   342  		if existingProjectCard != nil {
   343  			foundColumnID = colID.ID
   344  			break
   345  		}
   346  	}
   347  
   348  	// no need to move the card if it is in the same column
   349  	if (existingProjectCard != nil) && (proposedColumnID == foundColumnID) {
   350  		return nil
   351  	}
   352  
   353  	// Clear issue/PR from project if command is to clear
   354  	if shouldClear {
   355  		if existingProjectCard != nil {
   356  			if err := gc.DeleteProjectCard(org, existingProjectCard.ID); err != nil {
   357  				return err
   358  			}
   359  			msg = fmt.Sprintf(successClearingProjectMsg, proposedProject)
   360  			return gc.CreateComment(org, repo, e.Number, plugins.FormatResponseRaw(e.Body, e.HTMLURL, e.User.Login, msg))
   361  		}
   362  		msg = fmt.Sprintf(failedClearingProjectMsg, proposedProject, e.Number)
   363  		return gc.CreateComment(org, repo, e.Number, plugins.FormatResponseRaw(e.Body, e.HTMLURL, e.User.Login, msg))
   364  	}
   365  
   366  	// Move this issue/PR to the new column if there's already a project card for this issue/PR in this project
   367  	if existingProjectCard != nil {
   368  		log.Infof("Move card to column proposedColumnID: %v with issue: %v ", proposedColumnID, e.Number)
   369  		if err := gc.MoveProjectCard(org, existingProjectCard.ID, proposedColumnID); err != nil {
   370  			return err
   371  		}
   372  		msg = fmt.Sprintf(successMovingCardMsg, proposedColumnName, proposedColumnID)
   373  		return gc.CreateComment(org, repo, e.Number, plugins.FormatResponseRaw(e.Body, e.HTMLURL, e.User.Login, msg))
   374  	}
   375  
   376  	projectCard := github.ProjectCard{}
   377  	projectCard.ContentID = e.ID
   378  	if e.IsPR {
   379  		projectCard.ContentType = "PullRequest"
   380  	} else {
   381  		projectCard.ContentType = "Issue"
   382  	}
   383  
   384  	if _, err := gc.CreateProjectCard(org, proposedColumnID, projectCard); err != nil {
   385  		return err
   386  	}
   387  
   388  	msg = fmt.Sprintf(successCreatingCardMsg, proposedProject, proposedColumnName, proposedColumnID)
   389  	return gc.CreateComment(org, repo, e.Number, plugins.FormatResponseRaw(e.Body, e.HTMLURL, e.User.Login, msg))
   390  }