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 }