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 }