diff --git a/handler/comment_handler.go b/handler/comment_handler.go index 9f78b61..14c951d 100644 --- a/handler/comment_handler.go +++ b/handler/comment_handler.go @@ -36,6 +36,7 @@ const ( assignReviewerConstant string = "AssignReviewer" unassignReviewerConstant string = "UnassignReviewer" messageConstant string = "message" + mergePRConstant string = "Merge" noDCO string = "no-dco" labelLimitDefault int = 5 @@ -120,16 +121,34 @@ func HandleComment(req types.IssueCommentOuter, config config.Config, derekConfi feedback, err = createMessage(req, command.Type, command.Value, config, derekConfig) break + case mergePRConstant: + merger := merge{ + Config: config, + RepoConfig: derekConfig, + } + + feedback, err = merger.Merge(req, command.Type, command.Value) + + if len(feedback) > 0 { + log.Printf("Feedback: %s\n", feedback) + } + + if err != nil { + log.Println(err) + } + + break + default: feedback = "Unable to work with comment: " + req.Comment.Body err = nil break } - fmt.Print(feedback) + log.Print(feedback) if err != nil { - fmt.Println(err) + log.Println(err) } } @@ -434,6 +453,8 @@ func parse(body string, commandTriggers []string) *types.CommentAction { commandTrigger + "clear reviewer: ": unassignReviewerConstant, commandTrigger + "message: ": messageConstant, commandTrigger + "msg: ": messageConstant, + commandTrigger + "merge": mergePRConstant, + commandTrigger + "rebase": mergePRConstant, } for trigger, commandType := range commands { diff --git a/handler/merge.go b/handler/merge.go new file mode 100644 index 0000000..b6605e4 --- /dev/null +++ b/handler/merge.go @@ -0,0 +1,159 @@ +package handler + +import ( + "context" + "fmt" + "log" + + "github.com/alexellis/derek/config" + "github.com/alexellis/derek/types" + "github.com/google/go-github/github" +) + +type merge struct { + Config config.Config + RepoConfig *types.DerekRepoConfig +} + +func (m *merge) Merge(req types.IssueCommentOuter, cmdType string, cmdValue string) (string, error) { + result := "" + + client, ctx := makeClient(req.Installation.ID, m.Config) + + if req.Issue.PullRequest == nil { + return "can't merge a non-PR issue", nil + } + + if len(m.RepoConfig.Mergers) == 0 { + return "can't merge without at least one merger", nil + } + + if mayMerge(req.Comment.User.Login, m.RepoConfig.Mergers) == false { + return fmt.Sprintf("user %s, may not merge", req.Comment.User.Login), nil + } + + pr, _, err := client.PullRequests.Get(ctx, req.Repository.Owner.Login, req.Repository.Name, req.Issue.Number) + if err != nil { + return "unable to get pull request", err + } + + if pr.GetMerged() == false { + + if pr.GetMergeable() == true { + + if validMergePolicy(req) == false { + sendComment(client, req.Repository.Owner.Login, req.Repository.Name, req.Issue.Number, + "I am unable to merge this PR due to merge-policy exception(s)") + + return "invalid merge policy", nil + } + + mustApprove := []string{} + + // An approver, can't approve their own PRs so must come out + // of the list + for _, approver := range m.RepoConfig.MustApprove { + if approver != pr.GetUser().GetLogin() { + mustApprove = append(mustApprove, approver) + } + } + + if len(mustApprove) > 0 { + + listOpts := &github.ListOptions{} + reviews, _, listReviewsErr := client.PullRequests.ListReviews(ctx, req.Repository.Owner.Login, + req.Repository.Name, req.Issue.Number, listOpts) + + if listReviewsErr != nil { + return fmt.Sprintf("unable to list reviews for %d", pr.GetID()), listReviewsErr + } + + mustApproveConfirmed := []github.PullRequestReview{} + for _, r := range reviews { + fmt.Printf("Review state: %s, commitID: %s, HEADSHA: %s\n", r.GetState(), r.GetCommitID(), pr.GetHead().GetSHA()) + for _, approver := range mustApprove { + if r.GetState() == "APPROVED" && + r.GetUser().GetLogin() == approver && + r.GetCommitID() == pr.GetHead().GetSHA() { + mustApproveConfirmed = append(mustApproveConfirmed, *r) + } + } + } + + if len(m.RepoConfig.MustApprove) != len(mustApproveConfirmed) { + return fmt.Sprintf("needed %d approvals, but had: %d", + len(m.RepoConfig.MustApprove), len(mustApproveConfirmed)), nil + } + } + + pullRequestOptions := github.PullRequestOptions{ + MergeMethod: "rebase", + CommitTitle: fmt.Sprintf("Merge PR #%d", req.Issue.Number), + } + + mergeRes, _, err := client.PullRequests.Merge(ctx, + req.Repository.Owner.Login, req.Repository.Name, req.Issue.Number, + fmt.Sprintf(`Merging PR #%d by Derek + +This is an automated merge by the bot Derek, find more +https://github.com/alexellis/derek/ + +Signed-off-by: Derek `, req.Issue.Number), &pullRequestOptions) + + if err != nil { + + body := fmt.Sprintf(`I have been unable to merge the requested PR: %s`, err.Error()) + + sendComment(client, req.Repository.Owner.Login, req.Repository.Name, req.Issue.Number, + body) + + return fmt.Sprintf("Merge issue: %s, %t", mergeRes.GetMessage(), mergeRes.GetMerged()), err + } + + sendComment(client, req.Repository.Owner.Login, req.Repository.Name, req.Issue.Number, + `I have merged the pull request using the rebase strategy.`) + } else { + sendComment(client, req.Repository.Owner.Login, req.Repository.Name, req.Issue.Number, + "This pull request cannot be merged. Rebase your work and try again.") + } + } + + return result, err +} + +func sendComment(client *github.Client, login string, repo string, issue int, comment string) { + + issueComment := &github.IssueComment{ + Body: &comment, + } + + _, _, err := client.Issues.CreateComment(context.Background(), + login, repo, issue, issueComment) + if err != nil { + log.Printf("Error creating comment %s %s %d\n", login, repo, issue) + } +} + +func validMergePolicy(req types.IssueCommentOuter) bool { + validDCO := true + for _, label := range req.Issue.Labels { + if label.Name == "no-dco" { + validDCO = false + break + } + } + + return validDCO +} + +func mayMerge(user string, list []string) bool { + may := false + + for _, item := range list { + if item == user { + may = true + break + } + } + return may +} diff --git a/kumbaya b/kumbaya new file mode 160000 index 0000000..a4d0fb8 --- /dev/null +++ b/kumbaya @@ -0,0 +1 @@ +Subproject commit a4d0fb88dac0f30295a36a3885caed45c5b27fff diff --git a/types/types.go b/types/types.go index 65a740a..9cda2ca 100644 --- a/types/types.go +++ b/types/types.go @@ -56,6 +56,12 @@ type Issue struct { State string `json:"state"` Milestone Milestone `json:"milestone"` URL string `json:"url"` + + PullRequest *PullRequestIssueLink `json:"pull_request"` +} + +type PullRequestIssueLink struct { + URL string `json:"url"` } type Milestone struct { @@ -79,21 +85,27 @@ type CommentAction struct { type DerekRepoConfig struct { // A redirect URL to load the config from another location. - Redirect string + Redirect string `yaml:"redirect"` // Features can be turned on/off if needed. - Features []string + Features []string `yaml:"features"` // Users who are enrolled to make use of Derek - Maintainers []string + Maintainers []string `yaml:"maintainers"` // Curators is an alias for Maintainers and is only used if the Maintainers list is empty. - Curators []string + Curators []string `yaml:"curators"` //ContributingURL url to contribution guide ContributingURL string `yaml:"contributing_url"` Messages []Message `yaml:"custom_messages"` + + // Mergers are those who can perform a rebase + Mergers []string `yaml:"mergers"` + + // MustApprove are those from whom an approval must be in place for a merge + MustApprove []string `yaml:"must_approve"` } type Message struct {