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  }