diff --git a/prow/github/client.go b/prow/github/client.go index f60960d43f0a1..7ccbd575e40e9 100644 --- a/prow/github/client.go +++ b/prow/github/client.go @@ -2127,6 +2127,10 @@ type TransferIssueMutation struct { // TransferIssue will move an issue from one repo to another in the same org. // // See https://docs.github.com/en/graphql/reference/mutations#transferissue +// +// In the future we may want to interact with the TransferredEvent on the issue IssueTimeline +// See https://docs.github.com/en/graphql/reference/objects#transferredevent +// https://docs.github.com/en/graphql/reference/unions#issuetimelineitem func (c *client) TransferIssue(org, dstRepoNodeID string, issueNodeID string) (*TransferIssueMutation, error) { durationLogger := c.log("TransferIssue", org, dstRepoNodeID, issueNodeID) defer durationLogger() diff --git a/prow/github/fakegithub/fakegithub.go b/prow/github/fakegithub/fakegithub.go index 2b8908550ce6d..a27eb85057abe 100644 --- a/prow/github/fakegithub/fakegithub.go +++ b/prow/github/fakegithub/fakegithub.go @@ -20,6 +20,7 @@ import ( "context" "errors" "fmt" + "net/url" "regexp" "sort" "strings" @@ -133,6 +134,9 @@ type FakeClient struct { // Error will be returned if set. Currently only implemented for CreateStatus Error error + // GetRepoError will be returned if set when GetRepo is called + GetRepoError error + // WasLabelAddedByHumanVal determines the return of the method with the same name WasLabelAddedByHumanVal bool @@ -404,6 +408,29 @@ func (f *FakeClient) CloseIssue(org, repo string, number int) error { return nil } +func (f *FakeClient) TransferIssue(org, dstRepoNodeID string, issueNodeID string) (*github.TransferIssueMutation, error) { + f.lock.Lock() + defer f.lock.Unlock() + u, err := url.Parse(fmt.Sprintf("https://github.com/%s/dstRepo/1", org)) + if err != nil { + return nil, err + } + m := &github.TransferIssueMutation{ + TransferIssue: struct { + Issue struct { + URL githubql.URI + } + }{ + Issue: struct { + URL githubql.URI + }{ + URL: githubql.URI{URL: u}, + }, + }, + } + return m, nil +} + // GetPullRequestChanges returns the file modifications in a PR. func (f *FakeClient) GetPullRequestChanges(org, repo string, number int) ([]github.PullRequestChange, error) { f.lock.RLock() @@ -851,6 +878,9 @@ func (f *FakeClient) GetRepos(org string, isUser bool) ([]github.Repo, error) { } func (f *FakeClient) GetRepo(owner, name string) (github.FullRepo, error) { + if f.GetRepoError != nil { + return github.FullRepo{}, f.GetRepoError + } return github.FullRepo{ Repo: github.Repo{ Owner: github.User{Login: owner}, diff --git a/prow/plugins/transfer-issue/transfer-issue.go b/prow/plugins/transfer-issue/transfer-issue.go new file mode 100644 index 0000000000000..d2ba2dea773b8 --- /dev/null +++ b/prow/plugins/transfer-issue/transfer-issue.go @@ -0,0 +1,110 @@ +package transferissue + +import ( + "fmt" + "regexp" + + "github.com/sirupsen/logrus" + + "k8s.io/test-infra/prow/config" + "k8s.io/test-infra/prow/github" + "k8s.io/test-infra/prow/pluginhelp" + "k8s.io/test-infra/prow/plugins" +) + +const pluginName = "transfer-issue" + +var ( + transferRe = regexp.MustCompile(`(?mi)^/transfer(?:-issue)?\s*(.*)$`) +) + +type githubClient interface { + GetRepo(org, name string) (github.FullRepo, error) + CreateComment(org, repo string, number int, comment string) error + IsCollaborator(org, repo, user string) (bool, error) + TransferIssue(org, dstRepoNodeID string, issueNodeID string) (*github.TransferIssueMutation, error) +} + +func init() { + plugins.RegisterGenericCommentHandler(pluginName, handleGenericComment, helpProvider) +} + +func helpProvider(_ *plugins.Configuration, _ []config.OrgRepo) (*pluginhelp.PluginHelp, error) { + pluginHelp := &pluginhelp.PluginHelp{ + Description: "The transfer-issue plugin transfers a GitHub issue from one repo to another in the same organization.", + } + pluginHelp.AddCommand(pluginhelp.Command{ + Usage: "/transfer[-issue] ", + Description: "Transfers an issue to a different repo in the same org.", + Featured: true, + WhoCanUse: "Org members.", + Examples: []string{"/transfer-issue kubectl", "/transfer test-infra"}, + }) + return pluginHelp, nil +} + +func handleGenericComment(pc plugins.Agent, e github.GenericCommentEvent) error { + return handleTransfer(pc.GitHubClient, pc.Logger, e) +} + +func handleTransfer(gc githubClient, log *logrus.Entry, e github.GenericCommentEvent) error { + org := e.Repo.Owner.Login + srcRepoName := e.Repo.Name + srcRepoPair := org + "/" + srcRepoName + user := e.User.Login + + if e.IsPR || e.Action != github.GenericCommentActionCreated { + return nil + } + matches := transferRe.FindAllStringSubmatch(e.Body, -1) + if len(matches) == 0 { + return nil + } + if len(matches) != 1 || len(matches[0]) != 2 { + log.Warnf("invalid usage: %v", matches) + return gc.CreateComment( + org, srcRepoName, e.Number, + plugins.FormatResponseRaw(e.Body, e.HTMLURL, user, "/transfer-issue must only be used once and with a single destination repo."), + ) + } + + dstRepoName := matches[0][1] + dstRepoPair := org + "/" + dstRepoName + + dstRepo, err := gc.GetRepo(org, dstRepoName) + if err != nil { + log.WithError(err).Errorf("could not fetch destination repo: %s", dstRepoPair) + // TODO: Might want to add another GetRepo type call that checks if a repo exists vs a bad request + return gc.CreateComment( + org, srcRepoName, e.Number, + plugins.FormatResponseRaw(e.Body, e.HTMLURL, user, fmt.Sprintf("Something went wrong or the destination repo %s does not exist.", dstRepoPair)), + ) + } + + isCollaboratorSrc, err := gc.IsCollaborator(org, srcRepoName, user) + if err != nil { + log.WithError(err).Errorf("could not fetch if user: %s is collaborator of source repo: %s", user, srcRepoPair) + return err + } + isCollaboratorDst, err := gc.IsCollaborator(org, dstRepoName, user) + if err != nil { + log.WithError(err).Errorf("could not fetch if user: %s is collaborator of destination repo: %s", user, dstRepoPair) + return err + } + if !(isCollaboratorSrc && isCollaboratorDst) { + log.Warnf("user %s is not a collaborator of %s and/or %s", user, srcRepoPair, dstRepoPair) + return gc.CreateComment( + org, srcRepoName, e.Number, + plugins.FormatResponseRaw(e.Body, e.HTMLURL, user, "User must be a collaborator of source and destination repos."), + ) + } + + m, err := gc.TransferIssue(org, dstRepo.NodeID, e.NodeID) + if err != nil { + log.WithError(err).Errorf("could not transfer issue: %d from: %s to: %s", e.Number, srcRepoPair, dstRepoPair) + return err + } + log.Infof("%s transferred issue %s/%d to %v", user, srcRepoPair, e.Number, m.TransferIssue.Issue.URL) + + return nil +} diff --git a/prow/plugins/transfer-issue/transfer-issue_test.go b/prow/plugins/transfer-issue/transfer-issue_test.go new file mode 100644 index 0000000000000..dc16a3c61352f --- /dev/null +++ b/prow/plugins/transfer-issue/transfer-issue_test.go @@ -0,0 +1,138 @@ +package transferissue + +import ( + "errors" + "fmt" + "strings" + "testing" + + "github.com/sirupsen/logrus" + + "k8s.io/test-infra/prow/github" + "k8s.io/test-infra/prow/github/fakegithub" +) + +const issuerNum = 1 + +func Test_handleTransfer(t *testing.T) { + ts := []struct { + name string + event github.GenericCommentEvent + expectError bool + errorMessage string + comment string + fcFunc func(client *fakegithub.FakeClient) + }{ + { + name: "is a pr", + event: github.GenericCommentEvent{IsPR: true}, + }, + { + name: "is not comment added", + event: github.GenericCommentEvent{Action: github.GenericCommentActionDeleted}, + }, + { + name: "multiple matches", + event: github.GenericCommentEvent{ + Action: github.GenericCommentActionCreated, + Body: `/transfer-issue kubectl +/transfer test-infra`, + HTMLURL: fmt.Sprintf("https://github.com/kubernetes/fake/issues/%d", issuerNum), + Number: issuerNum, + Repo: github.Repo{Owner: github.User{Login: "org"}, Name: "repo"}, + User: github.User{Login: "user"}, + }, + comment: "single destination", + }, + { + name: "no destination", + event: github.GenericCommentEvent{ + Action: github.GenericCommentActionCreated, + Body: "/transfer", + HTMLURL: fmt.Sprintf("https://github.com/kubernetes/fake/issues/%d", issuerNum), + Number: issuerNum, + Repo: github.Repo{Owner: github.User{Login: "org"}, Name: "repo"}, + User: github.User{Login: "user"}, + }, + comment: "single destination", + }, + { + name: "dest repo does not exist", + event: github.GenericCommentEvent{ + Action: github.GenericCommentActionCreated, + Body: "/transfer-issue fake", + HTMLURL: fmt.Sprintf("https://github.com/kubernetes/fake/issues/%d", issuerNum), + Number: issuerNum, + Repo: github.Repo{Owner: github.User{Login: "kubernetes"}, Name: "kubectl"}, + User: github.User{Login: "user"}, + }, + comment: "does not exist", + fcFunc: func(fc *fakegithub.FakeClient) { + fc.GetRepoError = errors.New("stub") + }, + }, + { + name: "not collaborator", + event: github.GenericCommentEvent{ + Action: github.GenericCommentActionCreated, + Body: "/transfer-issue test-infra", + HTMLURL: fmt.Sprintf("https://github.com/kubernetes/fake/issues/%d", issuerNum), + Number: issuerNum, + Repo: github.Repo{Owner: github.User{Login: "kubernetes"}, Name: "kubectl"}, + User: github.User{Login: "user"}, + }, + comment: "must be a collaborator", + fcFunc: func(fc *fakegithub.FakeClient) { + fc.Collaborators = []string{} + }, + }, + { + name: "happy path", + event: github.GenericCommentEvent{ + Action: github.GenericCommentActionCreated, + Body: "/transfer-issue test-infra", + Number: issuerNum, + Repo: github.Repo{Owner: github.User{Login: "kubernetes"}, Name: "kubectl"}, + User: github.User{Login: "user"}, + }, + fcFunc: func(fc *fakegithub.FakeClient) { + fc.Collaborators = []string{"user"} + }, + }, + } + + for _, tc := range ts { + t.Run(tc.name, func(t *testing.T) { + fc := fakegithub.NewFakeClient() + if tc.fcFunc != nil { + tc.fcFunc(fc) + } + log := logrus.WithField("plugin", pluginName) + err := handleTransfer(fc, log, tc.event) + if err != nil { + if !tc.expectError { + t.Errorf("unexpected error: %v", err) + return + } + if m := err.Error(); !strings.Contains(m, tc.errorMessage) { + t.Errorf("expected error to contain: %s got: %v", tc.errorMessage, m) + return + } + } + if err == nil && tc.expectError { + t.Error("expected error but did not produce") + return + } + if len(tc.comment) != 0 { + if c, ok := fc.IssueComments[tc.event.Number]; ok { + if !strings.Contains(c[0].Body, tc.comment) { + t.Errorf("expected comment to contain: %s got: %s", tc.comment, c[0].Body) + } + } + } + if len(tc.comment) == 0 && len(fc.IssueComments[issuerNum]) != 0 { + t.Errorf("unexpected comment: %v", fc.IssueComments[issuerNum]) + } + }) + } +}