github.com/zppinho/prow@v0.0.0-20240510014325-1738badeb017/pkg/plugins/projectmanager/projectmanager.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 projectmanager is a plugin to auto add pull requests to project boards based on specified conditions
    18  package projectmanager
    19  
    20  import (
    21  	"fmt"
    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 (
    33  	pluginName = "project-manager"
    34  )
    35  
    36  var (
    37  	failedToAddProjectCard = "Failed to add project card for the issue/PR"
    38  	issueAlreadyInProject  = "The issue/PR %s already assigned to the project %s"
    39  
    40  	handleIssueActions = map[github.IssueEventAction]bool{
    41  		github.IssueActionOpened:    true,
    42  		github.IssueActionReopened:  true,
    43  		github.IssueActionLabeled:   true,
    44  		github.IssueActionUnlabeled: true,
    45  	}
    46  )
    47  
    48  /* Sample projectmanager configuration
    49  org/repos:
    50        org1/repo1:
    51          projects:
    52            test_project:
    53              columns:
    54                - id: 0
    55                  name: triage
    56                  state: open
    57                  org:  org1
    58                  labels:
    59                    - area/conformance
    60                      area/sig-testing
    61                - name: triage
    62                  state: open
    63                  org:  org1
    64                  labels:
    65                  - area/conformance
    66                    area/sig-testing
    67  */
    68  // TODO Handle Label deletion, pr/issue should be removed from the project when label criteria does  not meet
    69  // TODO Pr/issue state change, pr/issue is on project board only if its state is listed in the configuration
    70  func init() {
    71  	plugins.RegisterIssueHandler(pluginName, handleIssueOrPullRequest, helpProvider)
    72  }
    73  
    74  func helpProvider(config *plugins.Configuration, _ []config.OrgRepo) (*pluginhelp.PluginHelp, error) {
    75  	projectConfig := config.ProjectManager
    76  	if len(projectConfig.OrgRepos) == 0 {
    77  		pluginHelp := &pluginhelp.PluginHelp{
    78  			Description: "The project-manager plugin automatically adds Pull Requests to specified GitHub Project Columns, if the label on the PR matches with configured project and the column.",
    79  			Config:      map[string]string{},
    80  		}
    81  		return pluginHelp, nil
    82  	}
    83  
    84  	configString := map[string]string{}
    85  	repoDescr := ""
    86  	for orgRepoName, managedOrgRepo := range config.ProjectManager.OrgRepos {
    87  		for projectName, managedProject := range managedOrgRepo.Projects {
    88  			for _, managedColumn := range managedProject.Columns {
    89  				repoDescr = fmt.Sprintf("%s\nIssue/PRs org: %s, with matching labels: %s and state: %s will be added to the project: %s\n", repoDescr, managedColumn.Org, managedColumn.Labels, managedColumn.State, projectName)
    90  			}
    91  		}
    92  		configString[orgRepoName] = repoDescr
    93  	}
    94  	id := 123
    95  	yamlSnippet, err := plugins.CommentMap.GenYaml(&plugins.Configuration{
    96  		ProjectManager: plugins.ProjectManager{
    97  			OrgRepos: map[string]plugins.ManagedOrgRepo{
    98  				"org/repo": {
    99  					Projects: map[string]plugins.ManagedProject{
   100  						"project": {
   101  							Columns: []plugins.ManagedColumn{
   102  								{
   103  									ID:    &id,
   104  									Name:  "To do",
   105  									State: "open",
   106  									Labels: []string{
   107  										"area/conformance",
   108  									},
   109  									Org: "org",
   110  								},
   111  							},
   112  						},
   113  					},
   114  				},
   115  			},
   116  		},
   117  	})
   118  	if err != nil {
   119  		logrus.WithError(err).Warnf("cannot generate comments for %s plugin", pluginName)
   120  	}
   121  	pluginHelp := &pluginhelp.PluginHelp{
   122  		Description: "The project-manager plugin automatically adds Pull Requests to specified GitHub Project Columns, if the label on the PR matches with configured project and the column.",
   123  		Config:      configString,
   124  		Snippet:     yamlSnippet,
   125  	}
   126  	return pluginHelp, nil
   127  }
   128  
   129  // Strict subset of *github.Client methods.
   130  type githubClient interface {
   131  	GetIssueLabels(org, repo string, number int) ([]github.Label, error)
   132  	GetRepoProjects(owner, repo string) ([]github.Project, error)
   133  	GetOrgProjects(org string) ([]github.Project, error)
   134  	GetProjectColumns(org string, projectID int) ([]github.ProjectColumn, error)
   135  	GetColumnProjectCards(org string, columnID int) ([]github.ProjectCard, error)
   136  	CreateProjectCard(org string, columnID int, projectCard github.ProjectCard) (*github.ProjectCard, error)
   137  }
   138  
   139  type eventData struct {
   140  	id     int
   141  	number int
   142  	isPR   bool
   143  	org    string
   144  	repo   string
   145  	state  string
   146  	labels []github.Label
   147  	remove bool
   148  }
   149  
   150  type DuplicateCard struct {
   151  	projectName string
   152  	issueURL    string
   153  }
   154  
   155  func (m *DuplicateCard) Error() string {
   156  	return fmt.Sprintf(issueAlreadyInProject, m.issueURL, m.projectName)
   157  }
   158  
   159  func handleIssueOrPullRequest(pc plugins.Agent, ie github.IssueEvent) error {
   160  	if !handleIssueActions[ie.Action] {
   161  		return nil
   162  	}
   163  	eventData := eventData{
   164  		id:     ie.Issue.ID,
   165  		number: ie.Issue.Number,
   166  		isPR:   ie.Issue.IsPullRequest(),
   167  		org:    ie.Repo.Owner.Login,
   168  		repo:   ie.Repo.Name,
   169  		state:  ie.Issue.State,
   170  		labels: ie.Issue.Labels,
   171  		remove: ie.Action == github.IssueActionUnlabeled,
   172  	}
   173  
   174  	return handle(pc.GitHubClient, pc.PluginConfig.ProjectManager, pc.Logger, eventData)
   175  }
   176  
   177  func handle(gc githubClient, projectManager plugins.ProjectManager, log *logrus.Entry, e eventData) error {
   178  
   179  	// Get any ManagedProjects that match this PR
   180  	matchedColumnIDs := getMatchingColumnIDs(gc, projectManager.OrgRepos, e, log)
   181  
   182  	// For each ManagedColumn that matches this PR, add this PR to that Project Column
   183  	// All the matchedColumnID are valid column ids and the checked to see if the project card
   184  	// we are adding is not already part of the project and thus avoiding duplication.
   185  	for _, matchedColumnID := range matchedColumnIDs {
   186  		err := addIssueToColumn(gc, matchedColumnID, e)
   187  		if err != nil {
   188  			log.WithError(err).WithFields(logrus.Fields{
   189  				"matchedColumnID": matchedColumnID,
   190  			}).Error(failedToAddProjectCard)
   191  			return err
   192  		}
   193  	}
   194  	return nil
   195  }
   196  
   197  func getMatchingColumnIDs(gc githubClient, orgRepos map[string]plugins.ManagedOrgRepo, e eventData, log *logrus.Entry) []int {
   198  	var matchedColumnIDs []int
   199  	var err error
   200  	// Don't use GetIssueLabels unless it's required and keep track of whether the labels have been fetched to avoid unnecessary API usage.
   201  	if len(e.labels) == 0 {
   202  		e.labels, err = gc.GetIssueLabels(e.org, e.repo, e.number)
   203  		if err != nil {
   204  			log.Infof("Cannot get labels for issue/PR: %d, error: %s", e.number, err)
   205  		}
   206  	}
   207  
   208  	issueURL := fmt.Sprintf("https://api.github.com/repos/%s/%s/issues/%v", e.org, e.repo, e.number)
   209  	for orgRepoName, managedOrgRepo := range orgRepos {
   210  		for projectName, managedProject := range managedOrgRepo.Projects {
   211  			for _, managedColumn := range managedProject.Columns {
   212  				// Org is not specified or does not match we just ignore processing this column
   213  				if managedColumn.Org == "" || managedColumn.Org != e.org {
   214  					log.Infof("Ignoring column: {%v}, for issue/PR: %d, due to org: %v", managedColumn, e.number, e.org)
   215  					continue
   216  				}
   217  				// If state is not matching we ignore processing this column
   218  				// If state is empty then it defaults to 'open'
   219  				if managedColumn.State != "" && managedColumn.State != e.state {
   220  					log.Infof("Ignoring column: {%v}, for issue/PR: %d, due to state: %v", managedColumn, e.number, e.state)
   221  					continue
   222  				}
   223  
   224  				// if labels do not match we continue to the next project
   225  				// if labels are empty on the column, the match should return false
   226  				if !github.HasLabels(managedColumn.Labels, e.labels) {
   227  					log.Infof("Ignoring column: {%v}, for issue/PR: %d, labels due to labels: %v ", managedColumn, e.number, e.labels)
   228  					continue
   229  				}
   230  
   231  				columnID := managedColumn.ID
   232  				// Currently this assumes columnID having a value if 0 means it is unset
   233  				// While it's highly unlikely that an actual project would have an ID of 0, given that
   234  				// these IDs are global across GitHub, this doesn't seem like an ideal solution.
   235  				if columnID == nil {
   236  					var err error
   237  					columnID, err = getColumnID(gc, orgRepoName, projectName, managedColumn.Name, issueURL)
   238  					if err != nil {
   239  						if err, ok := err.(*DuplicateCard); ok {
   240  							log.Infof("Card already exists for issue: %s, under project: %s", err.issueURL, err.projectName)
   241  						}
   242  						log.Infof("Cannot add the issue/PR: %d to the project: %s, column: %s, error: %s", e.number, projectName, managedColumn.Name, err)
   243  
   244  						break
   245  					}
   246  				}
   247  				matchedColumnIDs = append(matchedColumnIDs, *columnID)
   248  				// if the configuration allows to match multiple columns within the same
   249  				// project, we will only take the first column match from the list
   250  				break
   251  			}
   252  		}
   253  	}
   254  	return matchedColumnIDs
   255  }
   256  
   257  // getColumnID returns a column id only if the issue if the project and column name provided are valid
   258  // and the issue is not already in the project
   259  func getColumnID(gc githubClient, orgRepoName, projectName, columnName, issueURL string) (*int, error) {
   260  	var projects []github.Project
   261  	var err error
   262  	orgRepoParts := strings.Split(orgRepoName, "/")
   263  	switch len(orgRepoParts) {
   264  	case 2:
   265  		projects, err = gc.GetRepoProjects(orgRepoParts[0], orgRepoParts[1])
   266  	case 1:
   267  		projects, err = gc.GetOrgProjects(orgRepoParts[0])
   268  	default:
   269  		return nil, fmt.Errorf("could not determine org or org/repo from %s", orgRepoName)
   270  	}
   271  
   272  	if err != nil {
   273  		return nil, err
   274  	}
   275  
   276  	for _, project := range projects {
   277  		if project.Name == projectName {
   278  			columns, err := gc.GetProjectColumns(orgRepoParts[0], project.ID)
   279  			if err != nil {
   280  				return nil, err
   281  			}
   282  
   283  			for _, column := range columns {
   284  				cards, err := gc.GetColumnProjectCards(orgRepoParts[0], column.ID)
   285  				if err != nil {
   286  					return nil, err
   287  				}
   288  
   289  				for _, card := range cards {
   290  					if card.ContentURL == issueURL {
   291  						return nil, &DuplicateCard{issueURL: issueURL, projectName: projectName}
   292  					}
   293  				}
   294  			}
   295  			for _, column := range columns {
   296  				if column.Name == columnName {
   297  					return &column.ID, nil
   298  				}
   299  			}
   300  			return nil, fmt.Errorf("could not find column %s in project %s", columnName, projectName)
   301  		}
   302  	}
   303  	return nil, fmt.Errorf("could not find project %s in org/repo %s", projectName, orgRepoName)
   304  }
   305  
   306  func addIssueToColumn(gc githubClient, columnID int, e eventData) error {
   307  	// Create project card and add this PR
   308  	projectCard := github.ProjectCard{}
   309  	if e.isPR {
   310  		projectCard.ContentType = "PullRequest"
   311  	} else {
   312  		projectCard.ContentType = "Issue"
   313  	}
   314  	projectCard.ContentID = e.id
   315  	_, err := gc.CreateProjectCard(e.org, columnID, projectCard)
   316  	return err
   317  }