github.com/shashidharatd/test-infra@v0.0.0-20171006011030-71304e1ca560/robots/issue-creator/creator/creator.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 creator 18 19 import ( 20 "bytes" 21 "errors" 22 "flag" 23 "fmt" 24 "io/ioutil" 25 "strings" 26 27 "github.com/google/go-github/github" 28 "k8s.io/test-infra/ghclient" 29 "k8s.io/test-infra/robots/issue-creator/testowner" 30 31 "github.com/golang/glog" 32 ) 33 34 // RepoClient is the interface IssueCreator used to interact with github. 35 // This interface is necessary for testing the IssueCreator with dependency injection. 36 type RepoClient interface { 37 GetUser(login string) (*github.User, error) 38 GetRepoLabels(org, repo string) ([]*github.Label, error) 39 GetIssues(org, repo string, options *github.IssueListByRepoOptions) ([]*github.Issue, error) 40 CreateIssue(org, repo, title, body string, labels, owners []string) (*github.Issue, error) 41 GetCollaborators(org, repo string) ([]*github.User, error) 42 } 43 44 // gihubClient is an wrapper of ghclient.Client that implements the RepoClient interface. 45 // This is used for dependency injection testing. 46 type githubClient struct { 47 *ghclient.Client 48 } 49 50 func (c githubClient) GetUser(login string) (*github.User, error) { 51 return c.Client.GetUser(login) 52 } 53 54 func (c githubClient) GetRepoLabels(org, repo string) ([]*github.Label, error) { 55 return c.Client.GetRepoLabels(org, repo) 56 } 57 58 func (c githubClient) GetIssues(org, repo string, options *github.IssueListByRepoOptions) ([]*github.Issue, error) { 59 return c.Client.GetIssues(org, repo, options) 60 } 61 62 func (c githubClient) CreateIssue(org, repo, title, body string, labels, owners []string) (*github.Issue, error) { 63 return c.Client.CreateIssue(org, repo, title, body, labels, owners) 64 } 65 66 // OwnerMapper finds an owner for a given test name. 67 type OwnerMapper interface { 68 // TestOwner returns a GitHub username for a test, or "" if none are found. 69 TestOwner(testName string) string 70 71 // TestSIG returns the name of the Special Interest Group (SIG) which owns the test , or "" if none are found. 72 TestSIG(testName string) string 73 } 74 75 // Issue is an interface implemented by structs that can be synced with github issues via the IssueCreator. 76 type Issue interface { 77 // Title yields the initial title text of the github issue. 78 Title() string 79 // Body yields the body text of the github issue and *must* contain the output of ID(). 80 // closedIssues is a (potentially empty) slice containing all closed 81 // issues authored by this bot that contain ID() in their body. 82 // if Body returns an empty string no issue is created. 83 Body(closedIssues []*github.Issue) string 84 // ID returns a string that uniquely identifies this issue. 85 // This ID must appear in the body of the issue. 86 // DO NOT CHANGE how this ID is formatted or duplicate issues will be created 87 // on github for this issue 88 ID() string 89 // Labels specifies the set of labels to apply to this issue on github. 90 Labels() []string 91 // Owners returns the github usernames to assign the issue to or nil/empty for no assignment. 92 Owners() []string 93 // Priority calculates and returns the priority of this issue 94 // The returned bool indicates if the returned priority is valid and can be used 95 Priority() (string, bool) 96 } 97 98 type IssueSource interface { 99 Issues(*IssueCreator) ([]Issue, error) 100 RegisterFlags() 101 } 102 103 // IssueCreator handles syncing identified issues with github issues. 104 // This includes finding existing github issues, creating new ones, and ensuring that duplicate 105 // github issues are not created. 106 type IssueCreator struct { 107 // client is the github client that is used to interact with github. 108 client RepoClient 109 // validLabels is the set of labels that are valid for the current repo (populated from github). 110 validLabels []string 111 // Collaborators is the set of Users that are valid assignees for the current repo (populated from GH). 112 Collaborators []string 113 // authorName is the name of the current bot. 114 authorName string 115 // allIssues is a local cache of all issues in the repo authored by the currently authenticated user. 116 // Issues are keyed by issue number. 117 allIssues map[int]*github.Issue 118 119 // ownerPath is the path the the test owners csv file or "" if no assignments or SIG areas should be used. 120 ownerPath string 121 // maxSIGCount is the maximum number of SIG areas to include on a single github issue. 122 MaxSIGCount int 123 // maxAssignees is the maximum number of user to assign to a single github issue. 124 MaxAssignees int 125 // tokenFIle is the file containing the github authentication token to use. 126 tokenFile string 127 // dryRun is true iff no modifying or 'write' operations should be made to github. 128 dryRun bool 129 // project is the name of the github repo. 130 project string 131 // org is the github organization that owns the repo. 132 org string 133 134 // Owners is an OwnerMapper that maps test names to owners and SIG areas. 135 Owners OwnerMapper 136 } 137 138 var sources = map[string]IssueSource{} 139 140 func RegisterSourceOrDie(name string, src IssueSource) { 141 if _, ok := sources[name]; ok { 142 glog.Fatalf("Cannot register an IssueSource with name %q, already exists!", name) 143 } 144 sources[name] = src 145 glog.Infof("Registered issue source '%s'.", name) 146 } 147 148 func (c *IssueCreator) initialize() error { 149 if c.org == "" { 150 return errors.New("'--org' is a required flag") 151 } 152 if c.project == "" { 153 return errors.New("'--project' is a required flag") 154 } 155 if c.tokenFile == "" { 156 return errors.New("'--token-file' is a required flag") 157 } 158 b, err := ioutil.ReadFile(c.tokenFile) 159 if err != nil { 160 return fmt.Errorf("failed to read token file '%s': %v", c.tokenFile, err) 161 } 162 token := strings.TrimSpace(string(b)) 163 164 c.client = RepoClient(githubClient{ghclient.NewClient(token, c.dryRun)}) 165 166 if c.ownerPath == "" { 167 c.Owners = nil 168 } else { 169 var err error 170 if c.Owners, err = testowner.NewReloadingOwnerList(c.ownerPath); err != nil { 171 return err 172 } 173 } 174 175 return c.loadCache() 176 } 177 178 // CreateAndSync is the main workhorse function of IssueCreator. It initializes the IssueCreator, 179 // asks each source for it's issues to sync, and syncs the issues. 180 func (c *IssueCreator) CreateAndSync() { 181 var err error 182 if err = c.initialize(); err != nil { 183 glog.Fatalf("Error initializing IssueCreator: %v.", err) 184 } 185 glog.Info("IssueCreator initialization complete.") 186 187 for srcName, src := range sources { 188 glog.Infof("Generating issues from source: %s.", srcName) 189 var issues []Issue 190 if issues, err = src.Issues(c); err != nil { 191 glog.Errorf("Error generating issues. Source: %s Msg: %v.", srcName, err) 192 continue 193 } 194 195 // Note: We assume that no issues made by this bot with ID's matching issues generated by 196 // sources will be created while this code is creating issues. If this is a possibility then 197 // this loop should be updated to fetch recently changed issues from github after every issue 198 // sync that results in an issue being created. 199 glog.Infof("Syncing issues from source: %s.", srcName) 200 created := 0 201 for _, issue := range issues { 202 if c.sync(issue) { 203 created++ 204 } 205 } 206 glog.Infof( 207 "Created issues for %d of the %d issues synced from source: %s.", 208 created, 209 len(issues), 210 srcName, 211 ) 212 } 213 } 214 215 // loadCache loads the valid labels for the repo, the currently authenticated user, and the issue cache from github. 216 func (c *IssueCreator) loadCache() error { 217 user, err := c.client.GetUser("") 218 if err != nil { 219 return fmt.Errorf("failed to fetch the User struct for the current authenticated user. errmsg: %v\n", err) 220 } 221 if user == nil { 222 return fmt.Errorf("received a nil User struct pointer when trying to look up the currently authenticated user.") 223 } 224 if user.Login == nil { 225 return fmt.Errorf("the user struct for the currently authenticated user does not specify a login.") 226 } 227 c.authorName = *user.Login 228 229 // Try to get the list of valid labels for the repo. 230 if validLabels, err := c.client.GetRepoLabels(c.org, c.project); err != nil { 231 c.validLabels = nil 232 glog.Errorf("Failed to retrieve the list of valid labels for repo '%s/%s'. Allowing all labels. errmsg: %v\n", c.org, c.project, err) 233 } else { 234 c.validLabels = make([]string, 0, len(validLabels)) 235 for _, label := range validLabels { 236 if label.Name != nil && *label.Name != "" { 237 c.validLabels = append(c.validLabels, *label.Name) 238 } 239 } 240 } 241 // Try to get the valid collaborators for the repo. 242 if collaborators, err := c.client.GetCollaborators(c.org, c.project); err != nil { 243 c.Collaborators = nil 244 glog.Errorf("Failed to retrieve the list of valid collaborators for repo '%s/%s'. Allowing all assignees. errmsg: %v\n", c.org, c.project, err) 245 } else { 246 c.Collaborators = make([]string, 0, len(collaborators)) 247 for _, user := range collaborators { 248 if user.Login != nil && *user.Login != "" { 249 c.Collaborators = append(c.Collaborators, strings.ToLower(*user.Login)) 250 } 251 } 252 } 253 254 // Populate the issue cache (allIssues). 255 issues, err := c.client.GetIssues( 256 c.org, 257 c.project, 258 &github.IssueListByRepoOptions{ 259 State: "all", 260 Creator: c.authorName, 261 }, 262 ) 263 if err != nil { 264 return fmt.Errorf("failed to refresh the list of all issues created by %s in repo '%s/%s'. errmsg: %v\n", c.authorName, c.org, c.project, err) 265 } 266 if len(issues) == 0 { 267 glog.Warningf("IssueCreator found no issues in the repo '%s/%s' authored by '%s'.\n", c.org, c.project, c.authorName) 268 } 269 c.allIssues = make(map[int]*github.Issue) 270 for _, i := range issues { 271 c.allIssues[*i.Number] = i 272 } 273 return nil 274 } 275 276 // RegisterOptions registers options for this munger; returns any that require a restart when changed. 277 func (c *IssueCreator) RegisterFlags() { 278 flag.StringVar(&c.ownerPath, "test-owners-csv", "", "file containing a CSV-exported test-owners spreadsheet") 279 flag.IntVar(&c.MaxSIGCount, "maxSIGs", 3, "The maximum number of SIG labels to attach to an issue.") 280 flag.IntVar(&c.MaxAssignees, "maxAssignees", 3, "The maximum number of users to assign to an issue.") 281 282 flag.StringVar(&c.tokenFile, "token-file", "", "The file containing the github authentication token to use.") 283 flag.StringVar(&c.project, "project", "", "The name of the github repo to create issues in.") 284 flag.StringVar(&c.org, "org", "", "The name of the organization that owns the repo to create issues in.") 285 flag.BoolVar(&c.dryRun, "dry-run", true, "True iff only 'read' operations should be made on github.") 286 287 for _, src := range sources { 288 src.RegisterFlags() 289 } 290 } 291 292 // setIntersect removes any elements from the first list that are not in the second, returning the 293 // new set and the removed elements. 294 func setIntersect(a, b []string) (filtered, removed []string) { 295 for _, elemA := range a { 296 found := false 297 for _, elemB := range b { 298 if elemA == elemB { 299 found = true 300 break 301 } 302 } 303 if found { 304 filtered = append(filtered, elemA) 305 } else { 306 removed = append(removed, elemA) 307 } 308 } 309 return 310 } 311 312 // sync checks to see if an issue is already on github and tries to create a new issue for it if it is not. 313 // True is returned iff a new issue is created. 314 func (c *IssueCreator) sync(issue Issue) bool { 315 // First look for existing issues with this ID. 316 id := issue.ID() 317 var closedIssues []*github.Issue 318 for _, i := range c.allIssues { 319 if strings.Contains(*i.Body, id) { 320 switch *i.State { 321 case "open": 322 //if an open issue is found with the ID then the issue is already synced 323 return false 324 case "closed": 325 closedIssues = append(closedIssues, i) 326 default: 327 glog.Errorf("Unrecognized issue state '%s' for issue #%d. Ignoring this issue.\n", *i.State, *i.Number) 328 } 329 } 330 } 331 // No open issues exist for the ID. 332 body := issue.Body(closedIssues) 333 if body == "" { 334 // Issue indicated that it should not be synced. 335 glog.Infof("Issue aborted sync by providing \"\" (empty) body. ID: %s.", id) 336 return false 337 } 338 if !strings.Contains(body, id) { 339 glog.Fatalf("Programmer error: The following body text does not contain id '%s'.\n%s\n", id, body) 340 } 341 342 title := issue.Title() 343 owners := issue.Owners() 344 if c.Collaborators != nil { 345 var removedOwners []string 346 owners, removedOwners = setIntersect(owners, c.Collaborators) 347 if len(removedOwners) > 0 { 348 glog.Errorf("Filtered the following invalid assignees from issue %q: %q.", title, removedOwners) 349 } 350 } 351 352 labels := issue.Labels() 353 if prio, ok := issue.Priority(); ok { 354 labels = append(labels, "priority/"+prio) 355 } 356 if c.validLabels != nil { 357 var removedLabels []string 358 labels, removedLabels = setIntersect(labels, c.validLabels) 359 if len(removedLabels) > 0 { 360 glog.Errorf("Filtered the following invalid labels from issue %q: %q.", title, removedLabels) 361 } 362 } 363 364 glog.Infof("Create Issue: %q Assigned to: %q\n", title, owners) 365 if c.dryRun { 366 return true 367 } 368 369 created, err := c.client.CreateIssue(c.org, c.project, title, body, labels, owners) 370 if err != nil { 371 glog.Errorf("Failed to create a new github issue for issue ID '%s'.\n", id) 372 return false 373 } 374 c.allIssues[*created.Number] = created 375 return true 376 } 377 378 // TestSIG uses the IssueCreator's OwnerMapper to look up the SIG for a test. 379 func (c *IssueCreator) TestSIG(testName string) string { 380 if c.Owners == nil { 381 return "" 382 } 383 return c.Owners.TestSIG(testName) 384 } 385 386 // TestOwner uses the IssueCreator's OwnerMapper to look up the user assigned to a test. 387 func (c *IssueCreator) TestOwner(testName string) string { 388 if c.Owners == nil { 389 return "" 390 } 391 owner := c.Owners.TestOwner(testName) 392 if !c.isAssignable(owner) { 393 return "" 394 } 395 return owner 396 } 397 398 // TestSIG uses the IssueCreator's OwnerMapper to look up the SIGs for a list of tests. 399 // The number of SIGs returned is limited by MaxSIGCount. 400 // The return value is a map from sigs to the tests from testNames that each sig owns. 401 func (c *IssueCreator) TestsSIGs(testNames []string) map[string][]string { 402 if c.Owners == nil { 403 return nil 404 } 405 sigs := make(map[string][]string) 406 for _, test := range testNames { 407 sig := c.Owners.TestSIG(test) 408 if sig == "" { 409 continue 410 } 411 412 if len(sigs) >= c.MaxSIGCount { 413 if tests, ok := sigs[sig]; ok { 414 sigs[sig] = append(tests, test) 415 } 416 } else { 417 sigs[sig] = append(sigs[sig], test) 418 } 419 } 420 return sigs 421 } 422 423 // TestOwner uses the IssueCreator's OwnerMapper to look up the users assigned to a list of tests. 424 // The number of users returned is limited by MaxAssignees. 425 // The return value is a map from users to the test names from testNames that each user owns. 426 func (c *IssueCreator) TestsOwners(testNames []string) map[string][]string { 427 if c.Owners == nil { 428 return nil 429 } 430 users := make(map[string][]string) 431 for _, test := range testNames { 432 user := c.TestOwner(test) 433 if user == "" { 434 continue 435 } 436 437 if len(users) >= c.MaxAssignees { 438 if tests, ok := users[user]; ok { 439 users[user] = append(tests, test) 440 } 441 } else { 442 users[user] = append(users[user], test) 443 } 444 } 445 return users 446 } 447 448 func (c *IssueCreator) ExplainTestAssignments(testNames []string) string { 449 assignees := c.TestsOwners(testNames) 450 sigs := c.TestsSIGs(testNames) 451 var buf bytes.Buffer 452 if len(assignees) > 0 || len(sigs) > 0 { 453 fmt.Fprint(&buf, "\n<details><summary>Rationale for assignments:</summary>\n") 454 fmt.Fprint(&buf, "\n| Assignee or SIG area | Owns test(s) |\n| --- | --- |\n") 455 for assignee, tests := range assignees { 456 if len(tests) > 3 { 457 tests = tests[0:3] 458 } 459 fmt.Fprintf(&buf, "| %s | %s |\n", assignee, strings.Join(tests, "; ")) 460 } 461 for sig, tests := range sigs { 462 if len(tests) > 3 { 463 tests = tests[0:3] 464 } 465 fmt.Fprintf(&buf, "| sig/%s | %s |\n", sig, strings.Join(tests, "; ")) 466 } 467 fmt.Fprint(&buf, "\n</details><br>\n") 468 } 469 return buf.String() 470 } 471 472 func (c *IssueCreator) isAssignable(login string) bool { 473 if c.Collaborators == nil { 474 return true 475 } 476 477 login = strings.ToLower(login) 478 for _, user := range c.Collaborators { 479 if user == login { 480 return true 481 } 482 } 483 return false 484 }