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