From cb09cc11870d4f93fcdc177f092d4e55b83905c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20J=C3=BCrgensmeyer?= Date: Thu, 4 Apr 2019 11:44:02 +0200 Subject: [PATCH] Public Release --- .gitignore | 58 +++++++++++ LICENSE | 21 ++++ README.md | 58 +++++++++++ gitlab/gitlab.go | 212 +++++++++++++++++++++++++++++++++++++++ go.mod | 3 + go.sum | 16 +++ main.go | 100 ++++++++++++++++++ mattermost/mattermost.go | 44 ++++++++ reviewers.json | 7 ++ templates/templates.go | 46 +++++++++ 10 files changed, 565 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 gitlab/gitlab.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 mattermost/mattermost.go create mode 100644 reviewers.json create mode 100644 templates/templates.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7d0f95f --- /dev/null +++ b/.gitignore @@ -0,0 +1,58 @@ + +# Created by https://www.gitignore.io/api/go,macos,visualstudiocode +# Edit at https://www.gitignore.io/?templates=go,macos,visualstudiocode + +### Go ### +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, build with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +### Go Patch ### +/vendor/ +/Godeps/ + +### macOS ### +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### VisualStudioCode ### +.vscode/* + +### VisualStudioCode Patch ### +# Ignore all local history of files +.history + +# End of https://www.gitignore.io/api/go,macos,visualstudiocode \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..efa5651 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018 Brabbler AG + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..3ae2825 --- /dev/null +++ b/README.md @@ -0,0 +1,58 @@ +# Review Reminder Bot + +## Usage + +### Command Line Flags + +``` text + -channel string + Mattermost channel (e.g. MyChannel) or user (e.g. @AnyUser) + -host string + GitLab host address (default "gitlab.com") + -project int + GitLab project id (default 1) + -reviewers string + file path to the reviewers config file (default "reviewers.json") + -token string + GitLab API token + -webhook string + Mattermost webhook URL +``` + +### Examples + +The reviewers.json file contains the `gitlab_user_id: "@mattermost_name"`. + +```json +{ + "5": "@hulk", + "17": "@iron_man", + "92": "@groot", + "95": "@batman", + "123": "@daredevil" +} +``` + +Get all open merge requests from project 1 and post it to the specified Mattermost channel: + +``` text +go run main.go -host=$GITLAB_HOST -token=$GITLAB_API_TOKEN -project=1 -webhook=$WEBHOOK_ADDRESS -channel=$MATTERMOST_CHANNEL +``` + +This will output the merge requests with the number of open discussions (💬) and the number of 👍 and 👎. The missing reviewers will be mentioned. +Adding the "sleeping" 😴 emoji to a merge request means the user won't review the code and/or doesn't want to be mentioned. +When all reviewers gave their thumps, the owner of the MR will be informed. + +``` markdown +**[Support SHIELD](https://gitlab.com/my_user/my_project/merge_requests/1940)** + 1 💬 3 :thumbsup: @hulk + +**[Ask Deadpool to join us](https://gitlab.com/my_user/my_project/merge_requests/1923)** + 3 💬 3 :thumbsup: @batman + +**[Repair the Helicarrier](https://gitlab.com/my_user/my_project/merge_requests/1777)** + 3 💬 @hulk @batman @groot @iron_man + +**[Find Kingpin](https://gitlab.com/my_user/my_project/merge_requests/1099)** + 2 💬 7 :thumbsup: You got all reviews, @daredevil. +``` \ No newline at end of file diff --git a/gitlab/gitlab.go b/gitlab/gitlab.go new file mode 100644 index 0000000..f559f56 --- /dev/null +++ b/gitlab/gitlab.go @@ -0,0 +1,212 @@ +package gitlab + +import ( + "fmt" + "log" + "net/http" + + "github.com/xanzy/go-gitlab" +) + +// NewClient returns a new gitlab client. +func NewClient(host, token string) *gitlab.Client { + client := gitlab.NewClient(nil, token) + if err := client.SetBaseURL(fmt.Sprintf("https://%s/api/v4", host)); err != nil { + log.Fatalf("failed to set gitlab host: %v", err) + } + return client +} + +// ResponsiblePerson returns the mattermost name of the assignee or author of the MR +// (fallback: gitlab author name) +func ResponsiblePerson(mr *gitlab.MergeRequest, reviewers map[int]string) string { + if mr.Assignee.ID != 0 { + assignee, ok := reviewers[mr.Assignee.ID] + if ok { + return assignee + } + } + + author, ok := reviewers[mr.Author.ID] + if ok { + return author + } + + return mr.Author.Name +} + +// OpenMergeRequests returns all open merge requests of the given project. +func OpenMergeRequests(git *gitlab.Client, projectID int) []*gitlab.MergeRequest { + // options + state := "opened" + opts := &gitlab.ListProjectMergeRequestsOptions{State: &state, ListOptions: gitlab.ListOptions{PerPage: 100}} + + // first page + mergeRequests, resp, err := git.MergeRequests.ListProjectMergeRequests(projectID, opts) + if err != nil { + log.Fatalf("failed to list project merge requests: %v", err) + } + if resp.StatusCode != http.StatusOK { + log.Fatalf("failed to list project merge requests, status code: %v", resp.StatusCode) + } + + // following pages + for page := 2; page <= resp.TotalPages; page++ { + opts.Page = page + + pageMRs, resp, err := git.MergeRequests.ListProjectMergeRequests(projectID, opts) + if err != nil { + log.Fatalf("failed to list project merge requests: %v", err) + } + if resp.StatusCode != http.StatusOK { + log.Fatalf("failed to list project merge requests, status code: %v", resp.StatusCode) + } + mergeRequests = append(mergeRequests, pageMRs...) + } + + return mergeRequests +} + +// LoadDiscussions of the given MR. +func LoadDiscussions(git *gitlab.Client, projectID int, mr *gitlab.MergeRequest) []*gitlab.Discussion { + opts := &gitlab.ListMergeRequestDiscussionsOptions{PerPage: 100} + + // first page + discussions, resp, err := git.Discussions.ListMergeRequestDiscussions(projectID, mr.IID, opts) + if err != nil { + log.Fatalf("failed to get discussions for mr %v: %v", mr.IID, err) + } + if resp.StatusCode != http.StatusOK { + log.Fatalf("failed to list emojis, status code: %v", resp.StatusCode) + } + + // following pages + for page := 2; page <= resp.TotalPages; page++ { + opts.Page = page + + pageDiscussions, resp, err := git.Discussions.ListMergeRequestDiscussions(projectID, mr.IID, opts) + if err != nil { + log.Fatalf("failed to list emojis for MR %v: %v", mr.IID, err) + } + if resp.StatusCode != http.StatusOK { + log.Fatalf("failed to list emojis, status code: %v", resp.StatusCode) + } + discussions = append(discussions, pageDiscussions...) + } + + return discussions +} + +// OpenDiscussionsCount returns the number of open discussions. +func OpenDiscussionsCount(discussions []*gitlab.Discussion) int { + // check if any of the discussions are unresolved + count := 0 + for _, d := range discussions { + for _, n := range d.Notes { + if !n.Resolved && n.Resolvable { + count++ + } + } + } + return count +} + +// FilterOpenDiscussions returns only merge requests which have no open discussions. +func FilterOpenDiscussions(mergeRequests []*gitlab.MergeRequest, discussions []*gitlab.Discussion) []*gitlab.MergeRequest { + result := []*gitlab.MergeRequest{} + + for _, mr := range mergeRequests { + // check if any of the discussions are unresolved + anyUnresolved := false + LoopDiscussions: + for _, d := range discussions { + for _, n := range d.Notes { + if !n.Resolved && n.Resolvable { + anyUnresolved = true + break LoopDiscussions + } + } + } + + // don't add merge request with unresolved discussion + if !anyUnresolved { + result = append(result, mr) + } + } + return result +} + +// LoadEmojis returns all emoji reactions of the particular MR. +func LoadEmojis(git *gitlab.Client, projectID int, mr *gitlab.MergeRequest) []*gitlab.AwardEmoji { + opts := &gitlab.ListAwardEmojiOptions{PerPage: 100} + + // first page + emojis, resp, err := git.AwardEmoji.ListMergeRequestAwardEmoji(projectID, mr.IID, opts) + if err != nil { + log.Fatalf("failed to list emojis for MR %v: %v", mr.IID, err) + } + + // following pages + for page := 2; page <= resp.TotalPages; page++ { + opts.Page = page + + pageEmojis, resp, err := git.AwardEmoji.ListMergeRequestAwardEmoji(projectID, mr.IID, opts) + if err != nil { + log.Fatalf("failed to list emojis for MR %v: %v", mr.IID, err) + } + if resp.StatusCode != http.StatusOK { + log.Fatalf("failed to list emojis, status code: %v", resp.StatusCode) + } + emojis = append(emojis, pageEmojis...) + } + + return emojis +} + +// GetReviewed returns the gitlab user id of the people who have already reviewed the MR. +// The emojis "thumbsup" 👍 and "thumbsdown" 👎 signal the user reviewed the merge request and won't receive a reminder. +// The emoji "sleeping" 😴 means the user won't review the code and/or doesn't want to be reminded. +func GetReviewed(mr *gitlab.MergeRequest, emojis []*gitlab.AwardEmoji) []int { + var reviewedBy []int + reviewedBy = append(reviewedBy, mr.Author.ID) + for _, emoji := range emojis { + if emoji.Name == "thumbsup" || emoji.Name == "thumbsdown" || emoji.Name == "sleeping" { + reviewedBy = append(reviewedBy, emoji.User.ID) + } + } + + return reviewedBy +} + +// AggregateEmojis lists all emojis with their usage count. +func AggregateEmojis(emojis []*gitlab.AwardEmoji) map[string]int { + var aggregate map[string]int + aggregate = make(map[string]int) + + for _, emoji := range emojis { + count := aggregate[emoji.Name] + count++ + aggregate[emoji.Name] = count + } + + return aggregate +} + +// MissingReviewers returns all reviewers which have not reacted with 👍, 👎 or 😴. +func MissingReviewers(reviewedBy []int, approvers map[int]string) []string { + var result []string + for userID, userName := range approvers { + approved := false + for _, approverID := range reviewedBy { + if userID == approverID { + approved = true + break + } + } + if !approved { + result = append(result, userName) + } + } + + return result +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..d2acdc4 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/sj14/review-reminder + +require github.com/xanzy/go-gitlab v0.11.6 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..048b2ce --- /dev/null +++ b/go.sum @@ -0,0 +1,16 @@ +github.com/golang/protobuf v1.2.0 h1:P3YflyNX/ehuJFLhxviNdFxQPkGK5cDcApsge1SqnvM= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk= +github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= +github.com/xanzy/go-gitlab v0.11.6 h1:UZDsJ6Q05sw1CtTq2ZX4R4Syg5NacdB5g2tdWBIIkTA= +github.com/xanzy/go-gitlab v0.11.6/go.mod h1:8zdQa/ri1dfn8eS3Ir1SyfvOKlw7WBJ8DVThkpGiXrs= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181108082009-03003ca0c849 h1:FSqE2GGG7wzsYUsWiQ8MZrvEd1EOyU3NCF0AW3Wtltg= +golang.org/x/net v0.0.0-20181108082009-03003ca0c849/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288 h1:JIqe8uIcRBHXDQVvZtHwp80ai3Lw3IJAeJEs55Dc1W0= +golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f h1:Bl/8QSvNqXvPGPGXa2z5xUTmV7VDcZyvRZ+QQXkXTZQ= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +google.golang.org/appengine v1.3.0 h1:FBSsiFRMz3LBeXIomRnVzrQwSDj4ibvcRexLG0LZGQk= +google.golang.org/appengine v1.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= diff --git a/main.go b/main.go new file mode 100644 index 0000000..82ae3fc --- /dev/null +++ b/main.go @@ -0,0 +1,100 @@ +package main + +import ( + "encoding/json" + "flag" + "fmt" + "io/ioutil" + "log" + + "github.com/sj14/review-reminder/gitlab" + "github.com/sj14/review-reminder/mattermost" + "github.com/sj14/review-reminder/templates" +) + +func main() { + var ( + host = flag.String("host", "gitlab.com", "GitLab host address") + glToken = flag.String("token", "", "GitLab API token") + projectID = flag.Int("project", 1, "GitLab project id") + reviewersPath = flag.String("reviewers", "reviewers.json", "file path to the reviewers config file") + webhook = flag.String("webhook", "", "Mattermost webhook URL") + channel = flag.String("channel", "", "Mattermost channel (e.g. MyChannel) or user (e.g. @AnyUser)") + ) + flag.Parse() + + // setup gitlab client + git := gitlab.NewClient(*host, *glToken) + + // load reviewers from given json file + reviewers := loadReviewers(*reviewersPath) + + // get open merge requests + mergeRequests := gitlab.OpenMergeRequests(git, *projectID) + + // only return merge requests which have no open discussions + // mergeRequests = filterOpenDiscussions(git, mergeRequests) + + // parse the reminder text template + template := templates.Get() + + // will contain the reminders of all merge requests + var reminderText string + + for _, mr := range mergeRequests { + // dont' check WIP MRs + if mr.WorkInProgress { + continue + } + + // load all emojis awarded to the mr + emojis := gitlab.LoadEmojis(git, *projectID, mr) + + // check who gave thumbs up/down (or "sleeping") + reviewedBy := gitlab.GetReviewed(mr, emojis) + + // who is missing thumbs up/down + missing := gitlab.MissingReviewers(reviewedBy, reviewers) + + // load all discussions of the mr + discussions := gitlab.LoadDiscussions(git, *projectID, mr) + + // get the number of open discussions + discussionsCount := gitlab.OpenDiscussionsCount(discussions) + + // get the responsible person of the mr + owner := gitlab.ResponsiblePerson(mr, reviewers) + + // list each emoji with the usage count + emojisAggr := gitlab.AggregateEmojis(emojis) + + // generate the reminder text for the current mr + reminderText += templates.Exec(template, mr, owner, missing, discussionsCount, emojisAggr) + } + + // print text of all aggregated reminders + fmt.Println(reminderText) + + if *channel != "" { + mattermost.Send(*channel, reminderText, *webhook) + } +} + +// load reviewers from given json file +// formatting: "GitLab UserID":"Mattermost Username" +// e.g. {"3":"@john.doe","5":"@max"} +func loadReviewers(reviewersPath string) map[int]string { + b, err := ioutil.ReadFile(reviewersPath) + if err != nil { + log.Fatalf("failed to read reviewers file: %v", err) + } + + // 'GitLab UserID': 'Mattermost Username' + reviewers := map[int]string{} + + if err := json.Unmarshal(b, &reviewers); err != nil { + log.Fatalf("failed to umarshal reviewers: %v", err) + } + + return reviewers +} diff --git a/mattermost/mattermost.go b/mattermost/mattermost.go new file mode 100644 index 0000000..c40a0e8 --- /dev/null +++ b/mattermost/mattermost.go @@ -0,0 +1,44 @@ +package mattermost + +import ( + "bytes" + "fmt" + "io/ioutil" + "log" + "net/http" + "os" +) + +// Send text to mattermost channel. +func Send(channel, text, webhook string) { + payload := []byte(fmt.Sprintf(`{"channel": "%s", "username": "Review Bot 🧐", "text": "%s"}`, channel, text)) + + req, err := http.NewRequest(http.MethodPost, webhook, bytes.NewBuffer(payload)) + if err != nil { + log.Fatalf("failed to create request: %v", err) + } + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + log.Fatalf("failed to send request: %v\n", err) + } + + defer func() { + if resp == nil || resp.Body == nil { + return + } + if err := resp.Body.Close(); err != nil { + log.Printf("failed to close mattermost client: %v\n", err) + } + }() + + if resp.StatusCode != http.StatusOK { + log.Println("response Status:", resp.Status) + log.Println("response Headers:", resp.Header) + body, _ := ioutil.ReadAll(resp.Body) + log.Println("response Body:", string(body)) + os.Exit(1) + } +} diff --git a/reviewers.json b/reviewers.json new file mode 100644 index 0000000..d720204 --- /dev/null +++ b/reviewers.json @@ -0,0 +1,7 @@ +{ + "5": "@hulk", + "17": "@iron_man", + "92": "@groot", + "95": "@batman", + "123": "@daredevil" +} \ No newline at end of file diff --git a/templates/templates.go b/templates/templates.go new file mode 100644 index 0000000..02da8aa --- /dev/null +++ b/templates/templates.go @@ -0,0 +1,46 @@ +package templates + +import ( + "bytes" + "log" + "text/template" + + gitlablib "github.com/xanzy/go-gitlab" +) + +// Get the template for the reminder message. +func Get() *template.Template { + // TODO: allow to load any template from a file + + const defaultTemplate = ` +**[{{.MR.Title}}]({{.MR.WebURL}})** +{{if .Discussions}} {{.Discussions}} 💬 {{end}} {{range $key, $value := .Emojis}} {{$value}} :{{$key}}: {{end}} {{range .Missing}}{{.}} {{else}}You got all reviews, {{.Owner}}.{{end}} +` + return template.Must(template.New("default").Parse(defaultTemplate)) +} + +// Exec the reminder message for the given merge request. +func Exec(template *template.Template, mr *gitlablib.MergeRequest, owner string, contacts []string, discussions int, emojis map[string]int) string { + data := struct { + MR *gitlablib.MergeRequest + Missing []string + Discussions int + Owner string + Emojis map[string]int + }{ + mr, + contacts, + discussions, + owner, + emojis, + } + + var buf []byte + buffer := bytes.NewBuffer(buf) + + if err := template.Execute(buffer, data); err != nil { + log.Fatalf("failed executing template: %v", err) + } + + return buffer.String() +}