Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion cmd/hook/plugin-imports/plugin-imports.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,6 @@ import (
_ "sigs.k8s.io/prow/pkg/plugins/slackevents"
_ "sigs.k8s.io/prow/pkg/plugins/stage"
_ "sigs.k8s.io/prow/pkg/plugins/testfreeze"
_ "sigs.k8s.io/prow/pkg/plugins/transfer-issue"
_ "sigs.k8s.io/prow/pkg/plugins/trick-or-treat"
_ "sigs.k8s.io/prow/pkg/plugins/trigger"
_ "sigs.k8s.io/prow/pkg/plugins/updateconfig"
Expand Down
1 change: 0 additions & 1 deletion pkg/hook/plugin-imports/plugin-imports.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,6 @@ import (
_ "sigs.k8s.io/prow/pkg/plugins/slackevents"
_ "sigs.k8s.io/prow/pkg/plugins/stage"
_ "sigs.k8s.io/prow/pkg/plugins/testfreeze"
_ "sigs.k8s.io/prow/pkg/plugins/transfer-issue"
_ "sigs.k8s.io/prow/pkg/plugins/trick-or-treat"
_ "sigs.k8s.io/prow/pkg/plugins/trigger"
_ "sigs.k8s.io/prow/pkg/plugins/updateconfig"
Expand Down
40 changes: 40 additions & 0 deletions pkg/plugins/issue-management/issue_management.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ var (
unlinkIssueRegex = regexp.MustCompile(`(?mi)^/unlink-issue\s+(.+)$`)
pinRegex = regexp.MustCompile(`(?mi)^/pin-issue\s*$`)
unpinRegex = regexp.MustCompile(`(?mi)^/unpin-issue\s*$`)
transferRegex = regexp.MustCompile(`(?mi)^/transfer(?:-issue)?(?: +(.*))?$`)
)

type githubClient interface {
Expand Down Expand Up @@ -83,6 +84,13 @@ func helpProvider(_ *plugins.Configuration, _ []config.OrgRepo) (*pluginhelp.Plu
WhoCanUse: "Approvers from the top-level OWNERS file",
Examples: []string{"/unpin-issue"},
})
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
}

Expand Down Expand Up @@ -113,9 +121,41 @@ func handleIssues(gc githubClient, oc ownersClient, log *logrus.Entry, e github.
return handlePinOrUnpinIssue(gc, oc, log, e, false)
}

if destRepo, err := parseTransferCommand(gc, e); err != nil {
return err
} else if destRepo != "" {
return handleTransferIssue(gc, log, e, destRepo)
}

return nil
}

func parseTransferCommand(gc githubClient, e github.GenericCommentEvent) (string, error) {
matches := transferRegex.FindAllStringSubmatch(e.Body, -1)
if len(matches) == 0 {
return "", nil
}

if e.IsPR {
return "", gc.CreateComment(
e.Repo.Owner.Login, e.Repo.Name, e.Number,
plugins.FormatResponseRaw(e.Body, e.HTMLURL, e.User.Login, "The `/transfer-issue` command is only supported on issues, not pull requests."),
)
}

if e.Action != github.GenericCommentActionCreated {
return "", nil
}

if len(matches) != 1 || len(matches[0]) != 2 || len(matches[0][1]) == 0 {
return "", gc.CreateComment(
e.Repo.Owner.Login, e.Repo.Name, e.Number,
plugins.FormatResponseRaw(e.Body, e.HTMLURL, e.User.Login, "`/transfer-issue` must only be used once and with a single destination repo."),
)
}
return strings.TrimSpace(matches[0][1]), nil
}

func parseCommentForLinkCommands(commentBody string) ([]string, []string) {
extractIssues := func(re *regexp.Regexp) []string {
var issues []string
Expand Down
123 changes: 101 additions & 22 deletions pkg/plugins/issue-management/issue_management_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,13 @@ func TestHandleIssues(t *testing.T) {
log := logrus.WithField("test", "handleIssues")

tests := []struct {
name string
commentBody string
fc func(*fakegithub.FakeClient)
froc func() *fakeRepoownersClient
event github.GenericCommentEvent
expectError bool
errorContains string
name string
commentBody string
fc func(*fakegithub.FakeClient)
froc func() *fakeRepoownersClient
event github.GenericCommentEvent
expectComment bool
commentContains string
}{
{
name: "Routes to link-issue handler when /link-issue command is present",
Expand Down Expand Up @@ -63,7 +63,6 @@ func TestHandleIssues(t *testing.T) {
Repo: github.Repo{Owner: github.User{Login: "org"}, Name: "repo"},
User: github.User{Login: "user"},
},
expectError: false,
},
{
name: "Routes to unlink-issue handler when /unlink-issue command is present",
Expand Down Expand Up @@ -91,7 +90,6 @@ func TestHandleIssues(t *testing.T) {
Repo: github.Repo{Owner: github.User{Login: "org"}, Name: "repo"},
User: github.User{Login: "user"},
},
expectError: false,
},
{
name: "Routes to pin-issue handler when /pin-issue command is present",
Expand All @@ -113,7 +111,6 @@ func TestHandleIssues(t *testing.T) {
Repo: github.Repo{Owner: github.User{Login: "org"}, Name: "repo"},
User: github.User{Login: "user"},
},
expectError: false,
},
{
name: "Routes to unpin-issue handler when /unpin-issue command is present",
Expand All @@ -135,7 +132,6 @@ func TestHandleIssues(t *testing.T) {
Repo: github.Repo{Owner: github.User{Login: "org"}, Name: "repo"},
User: github.User{Login: "user"},
},
expectError: false,
},
{
name: "Returns nil when no matching command is found",
Expand All @@ -154,7 +150,6 @@ func TestHandleIssues(t *testing.T) {
Repo: github.Repo{Owner: github.User{Login: "org"}, Name: "repo"},
User: github.User{Login: "user"},
},
expectError: false,
},
{
name: "Handles case insensitive commands",
Expand All @@ -176,7 +171,90 @@ func TestHandleIssues(t *testing.T) {
Repo: github.Repo{Owner: github.User{Login: "org"}, Name: "repo"},
User: github.User{Login: "user"},
},
expectError: false,
},
{
name: "Routes to transfer-issue handler when /transfer-issue command is present",
commentBody: "/transfer-issue test-repo",
fc: func(fc *fakegithub.FakeClient) {
fc.IssueComments = map[int][]github.IssueComment{}
fc.OrgMembers = map[string][]string{
"org": {"user"},
}
},
froc: func() *fakeRepoownersClient {
return newFakeRepoownersClient([]string{"approver1"})
},
event: github.GenericCommentEvent{
IsPR: false,
Action: github.GenericCommentActionCreated,
Body: "/transfer-issue test-repo",
Number: 1,
Repo: github.Repo{Owner: github.User{Login: "org"}, Name: "repo"},
User: github.User{Login: "user"},
NodeID: "issueNodeID",
},
},
{
name: "Routes to transfer handler when /transfer command is present",
commentBody: "/transfer another-repo",
fc: func(fc *fakegithub.FakeClient) {
fc.IssueComments = map[int][]github.IssueComment{}
fc.OrgMembers = map[string][]string{
"org": {"user"},
}
},
froc: func() *fakeRepoownersClient {
return newFakeRepoownersClient([]string{"approver1"})
},
event: github.GenericCommentEvent{
IsPR: false,
Action: github.GenericCommentActionCreated,
Body: "/transfer another-repo",
Number: 1,
Repo: github.Repo{Owner: github.User{Login: "org"}, Name: "repo"},
User: github.User{Login: "user"},
NodeID: "issueNodeID",
},
},
{
name: "Returns with comment when transfer command has multiple destinations",
commentBody: "/transfer-issue repo1\n/transfer repo2",
fc: func(fc *fakegithub.FakeClient) {
fc.IssueComments = map[int][]github.IssueComment{}
},
froc: func() *fakeRepoownersClient {
return newFakeRepoownersClient([]string{"approver1"})
},
event: github.GenericCommentEvent{
IsPR: false,
Action: github.GenericCommentActionCreated,
Body: "/transfer-issue repo1\n/transfer repo2",
Number: 1,
Repo: github.Repo{Owner: github.User{Login: "org"}, Name: "repo"},
User: github.User{Login: "user"},
},
expectComment: true,
commentContains: "must only be used once",
},
{
name: "Returns with comment when transfer command has no destination",
commentBody: "/transfer",
fc: func(fc *fakegithub.FakeClient) {
fc.IssueComments = map[int][]github.IssueComment{}
},
froc: func() *fakeRepoownersClient {
return newFakeRepoownersClient([]string{"approver1"})
},
event: github.GenericCommentEvent{
IsPR: false,
Action: github.GenericCommentActionCreated,
Body: "/transfer",
Number: 1,
Repo: github.Repo{Owner: github.User{Login: "org"}, Name: "repo"},
User: github.User{Login: "user"},
},
expectComment: true,
commentContains: "single destination repo",
},
}

Expand All @@ -192,15 +270,16 @@ func TestHandleIssues(t *testing.T) {

err := handleIssues(gc, oc, log, tc.event)

if tc.expectError {
if err == nil {
t.Errorf("Expected error but got none")
} else if tc.errorContains != "" && !strings.Contains(err.Error(), tc.errorContains) {
t.Errorf("Expected error to contain %q but got: %v", tc.errorContains, err)
}
} else {
if err != nil {
t.Errorf("Expected no error but got: %v", err)
if err != nil {
t.Errorf("Expected no error but got: %v", err)
}

if tc.expectComment {
comments := fc.IssueCommentsAdded
if len(comments) == 0 {
t.Errorf("Expected comment but none were created")
} else if !strings.Contains(comments[0], tc.commentContains) {
t.Errorf("Expected comment to contain %q but got: %q", tc.commentContains, comments[0])
}
}
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,88 +16,33 @@ limitations under the License.

// Package transferissue implements the `/transfer-issue` command which allows members of the org
// to transfer issues between repos
package transferissue
package issuemanagement

import (
"context"
"fmt"
"regexp"
"strings"

githubql "github.com/shurcooL/githubv4"
"github.com/sirupsen/logrus"

"sigs.k8s.io/prow/pkg/config"
"sigs.k8s.io/prow/pkg/github"
"sigs.k8s.io/prow/pkg/pluginhelp"
"sigs.k8s.io/prow/pkg/plugins"
)

const pluginName = "transfer-issue"

var (
transferRe = regexp.MustCompile(`(?mi)^/transfer(?:-issue)?(?: +(.*))?$`)
)

type githubClient interface {
GetRepo(org, name string) (github.FullRepo, error)
CreateComment(org, repo string, number int, comment string) error
IsMember(org, user string) (bool, error)
MutateWithGitHubAppsSupport(context.Context, any, githubql.Input, map[string]any, string) 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 {
func handleTransferIssue(gc githubClient, log *logrus.Entry, e github.GenericCommentEvent, dstRepoName string) 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 || len(matches[0][1]) == 0 {
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 := strings.TrimSpace(matches[0][1])
dstRepoPair := org + "/" + dstRepoName
user := e.User.Login

dstRepo, err := gc.GetRepo(org, dstRepoName)
if err != nil {
log.WithError(err).WithField("dstRepo", dstRepoPair).Warning("could not fetch destination repo")
// 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)),
plugins.FormatResponseRaw(e.Body, e.HTMLURL, user, fmt.Sprintf("Something went wrong or the destination repo **%s** does not exist.", dstRepoPair)),
)
}

Expand Down
Loading