Skip to content

Commit

Permalink
Add transfer-issue prow plugin
Browse files Browse the repository at this point in the history
Signed-off-by: Eddie Zaneski <eddiezane@gmail.com>
  • Loading branch information
eddiezane committed Aug 17, 2021
1 parent 4024d2d commit aa2f62d
Show file tree
Hide file tree
Showing 4 changed files with 282 additions and 0 deletions.
4 changes: 4 additions & 0 deletions prow/github/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
30 changes: 30 additions & 0 deletions prow/github/fakegithub/fakegithub.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"context"
"errors"
"fmt"
"net/url"
"regexp"
"sort"
"strings"
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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},
Expand Down
110 changes: 110 additions & 0 deletions prow/plugins/transfer-issue/transfer-issue.go
Original file line number Diff line number Diff line change
@@ -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] <destination repo in same org>",
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
}
138 changes: 138 additions & 0 deletions prow/plugins/transfer-issue/transfer-issue_test.go
Original file line number Diff line number Diff line change
@@ -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])
}
})
}
}

0 comments on commit aa2f62d

Please sign in to comment.