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  }