├── .gitignore ├── Dockerfile ├── Godeps ├── Godeps.json ├── Readme └── _workspace │ └── .gitignore ├── LICENSE.txt ├── Procfile ├── README.md ├── circle.yml ├── config ├── config.go └── config_test.go ├── git ├── git.go └── git_test.go ├── github ├── comment.go ├── error.go ├── event.go ├── git_ref.go ├── github.go ├── issue.go ├── pull_request.go ├── repository.go └── user.go ├── http ├── helper.go ├── helper_test.go ├── rebase.go └── status.go ├── integrations └── git.go └── main.go /.gitignore: -------------------------------------------------------------------------------- 1 | rebasebot 2 | rebasebot.json 3 | .DS_Store 4 | tmp/ 5 | .env 6 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.5 2 | MAINTAINER Chris Ledet 3 | 4 | ENV GOPATH /go 5 | ENV GOROOT /usr/local/go 6 | ENV PATH $PATH:$GOROOT/bin 7 | 8 | # Install rebasebot 9 | RUN go get -u github.com/chrisledet/rebasebot 10 | RUN go install github.com/chrisledet/rebasebot 11 | 12 | # Configure Git 13 | RUN git config --global user.name "Rebase Bot" 14 | RUN git config --global user.email "rebase-bot@users.noreply.github.com" 15 | 16 | # Set default container command 17 | ENTRYPOINT $GOPATH/bin/rebasebot 18 | -------------------------------------------------------------------------------- /Godeps/Godeps.json: -------------------------------------------------------------------------------- 1 | { 2 | "ImportPath": "github.com/chrisledet/rebasebot", 3 | "GoVersion": "go1.6.2", 4 | "Deps": [] 5 | } 6 | -------------------------------------------------------------------------------- /Godeps/Readme: -------------------------------------------------------------------------------- 1 | This directory tree is generated automatically by godep. 2 | 3 | Please do not edit. 4 | 5 | See https://github.com/tools/godep for more information. 6 | -------------------------------------------------------------------------------- /Godeps/_workspace/.gitignore: -------------------------------------------------------------------------------- 1 | /pkg 2 | /bin 3 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Chris Ledet 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: rebasebot 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # rebasebot [![Circle CI](https://circleci.com/gh/chrisledet/rebasebot.svg?style=svg)](https://circleci.com/gh/chrisledet/rebasebot) 2 | 3 | A GitHub bot that rebases your pull request branches when you ask 4 | 5 | ## How it works 6 | 7 | 1. Make a dedicated GitHub account for the bot 8 | 2. Grant the GitHub account read and write access to your repositories 9 | 3. [Setup](#setup) the bot on your own server 10 | 4. Type a comment "**@{github bot username} rebase**" in a pull request 11 | 5. The bot will then kick off a rebase and push (if rebase successful) to your repository 12 | 6. You can then delete the comment (in step 4) if you want to, including the rebase comment from the bot. 13 | 14 | ## Dependencies 15 | 16 | * Dedicated host (e.g. EC2, Digital Ocean, Rackspace) 17 | * Go 1.5 18 | * Git 19 | * Dedicated GitHub account 20 | 21 | ## Setup 22 | 23 | ### Download 24 | 25 | ```shell 26 | $ go get github.com/chrisledet/rebasebot 27 | ``` 28 | 29 | ### Build 30 | 31 | ```shell 32 | $ cd $GOPATH/src/github.com/chrisledet/rebasebot 33 | $ go build 34 | ``` 35 | 36 | ### Install 37 | 38 | Make sure `$GOPATH/bin` is located in your `$PATH` 39 | 40 | ```shell 41 | $ go install 42 | ``` 43 | 44 | ### Configuration 45 | 46 | Here are the environment variables rebasebot uses: 47 | 48 | * `GITHUB_USERNAME`: GitHub username for bot. Required. 49 | * `GITHUB_PASSWORD`: GitHub password for bot. Required. 50 | * `PORT`: HTTP server port for the bot. Required. 51 | * `TMPDIR`: A path to a writable directory. All local copies will live here. Defaults to OS tmp. **Strongly recommended**. 52 | * `SECRET`: A token used to verify web hook requests from GitHub. **Strongly recommended**. 53 | 54 | The `GITHUB_*` are needed so the bot can post activity updates to GitHub as well as push to accessible Git repositories. Using your personal credentials is **not recommended**. 55 | 56 | ### Run 57 | 58 | ```shell 59 | $ $GOPATH/bin/rebasebot 60 | ``` 61 | 62 | ### Add GitHub Webhook 63 | 64 | This is a required step to complete the setup. 65 | 66 | 1. Go into your GitHub repository's Webhooks and services page 67 | 2. Add webhook 68 | 1. Enter `http:///rebase` in the "Payload URL" field 69 | 2. Content type should be set to "application/json" 70 | 3. Generate a secret token and enter it in "Secret" field 71 | 4. Only send "Issue comment" events. All other ones will be ignored. 72 | 3. GitHub should succesfully ping the service and receive a HTTP 200 OK 73 | 74 | ## Resources 75 | 76 | * GitHub guide for [securing your webhooks](https://developer.github.com/webhooks/securing/) 77 | 78 | * Generate secret token with Ruby 79 | 80 | ```shell 81 | $ ruby -rsecurerandom -e 'puts SecureRandom.hex(20)' 82 | ``` 83 | -------------------------------------------------------------------------------- /circle.yml: -------------------------------------------------------------------------------- 1 | dependencies: 2 | pre: 3 | - go get -u github.com/jstemmer/go-junit-report 4 | test: 5 | pre: 6 | - mkdir -p $CIRCLE_TEST_REPORTS/junit/ 7 | override: 8 | - go test -v -race ./... | go-junit-report > $CIRCLE_TEST_REPORTS/junit/report.xml 9 | -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | // Package config provides a simple interface for rebasebot configuration 2 | package config 3 | 4 | import ( 5 | "errors" 6 | "io/ioutil" 7 | "os" 8 | "strings" 9 | ) 10 | 11 | type Config struct { 12 | Username string `json:"username"` 13 | Password string `json:"password"` 14 | Port string `json:"port"` 15 | Secret string `json:"secret"` 16 | TmpDir string `json:"tmpdir"` 17 | } 18 | 19 | func NewConfig() (*Config, error) { 20 | config := &Config{Port: "8080"} 21 | 22 | requiredEnvVars := []string{"PORT", "GITHUB_USERNAME", "GITHUB_PASSWORD"} 23 | for _, envVar := range requiredEnvVars { 24 | if len(os.Getenv(envVar)) == 0 { 25 | return config, errors.New(envVar + " must be set") 26 | } 27 | } 28 | 29 | config.Username = os.Getenv("GITHUB_USERNAME") 30 | config.Password = os.Getenv("GITHUB_PASSWORD") 31 | config.Port = os.Getenv("PORT") 32 | config.Secret = os.Getenv("SECRET") 33 | config.TmpDir = os.Getenv("TMPDIR") 34 | 35 | return config, nil 36 | } 37 | 38 | func NewDevConfig() (*Config, error) { 39 | fileInBytes, err := ioutil.ReadFile(".env") 40 | if err != nil { 41 | return nil, err 42 | } 43 | 44 | fileContents := string(fileInBytes) 45 | fileContentsByLine := strings.Split(fileContents, "\n") 46 | 47 | for _, line := range fileContentsByLine { 48 | fileContentsByLine := strings.Split(strings.TrimSpace(line), "=") 49 | if len(fileContentsByLine) == 2 { 50 | os.Setenv(fileContentsByLine[0], fileContentsByLine[1]) 51 | } 52 | } 53 | 54 | return NewConfig() 55 | } 56 | -------------------------------------------------------------------------------- /config/config_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | ) 7 | 8 | func TestNewConfig(t *testing.T) { 9 | 10 | _, err := NewConfig() 11 | 12 | if err != nil { 13 | t.Errorf("error was returned: %s \n", err.Error()) 14 | } 15 | } 16 | 17 | func TestNewConfigReturningErrorMissingPort(t *testing.T) { 18 | os.Setenv("PORT", "") 19 | 20 | if _, err := NewConfig(); err == nil { 21 | t.Error("error was not returned") 22 | } 23 | } 24 | 25 | func TestNewConfigReturningErrorMissingGitHubUsername(t *testing.T) { 26 | os.Setenv("GITHUB_USERNAME", "") 27 | 28 | if _, err := NewConfig(); err == nil { 29 | t.Error("error was not returned") 30 | } 31 | } 32 | 33 | func TestNewConfigReturningErrorMissingGitHubPassword(t *testing.T) { 34 | os.Setenv("GITHUB_PASSWORD", "") 35 | 36 | if _, err := NewConfig(); err == nil { 37 | t.Error("error was not returned") 38 | } 39 | } 40 | 41 | func TestMain(m *testing.M) { 42 | os.Setenv("PORT", "port") 43 | os.Setenv("GITHUB_USERNAME", "username") 44 | os.Setenv("GITHUB_PASSWORD", "password") 45 | 46 | os.Exit(m.Run()) 47 | } 48 | -------------------------------------------------------------------------------- /git/git.go: -------------------------------------------------------------------------------- 1 | // Package git provides basic git client functionality 2 | package git 3 | 4 | import ( 5 | "fmt" 6 | "log" 7 | "os" 8 | "os/exec" 9 | "path" 10 | "strings" 11 | ) 12 | 13 | var ( 14 | repoParentDir string 15 | username string 16 | password string 17 | gitName string 18 | gitEmail string 19 | ) 20 | 21 | type Output struct { 22 | Buffer string 23 | } 24 | 25 | func (w *Output) Write(b []byte) (int, error) { 26 | w.Buffer = w.Buffer + string(b) 27 | 28 | return len(b), nil 29 | } 30 | 31 | func (o *Output) String() string { 32 | return o.Buffer 33 | } 34 | 35 | func init() { 36 | tmpDirOverride := os.Getenv("TMPDIR") 37 | 38 | if len(tmpDirOverride) > 0 { 39 | repoParentDir = tmpDirOverride 40 | } else { 41 | repoParentDir = os.TempDir() 42 | } 43 | 44 | username = os.Getenv("GITHUB_USERNAME") 45 | password = os.Getenv("GITHUB_PASSWORD") 46 | gitName = os.Getenv("GIT_USER") 47 | gitEmail = os.Getenv("GIT_EMAIL") 48 | 49 | if len(gitName) < 1 { 50 | gitName = username 51 | } 52 | 53 | if len(gitEmail) < 1 { 54 | gitEmail = fmt.Sprintf("%s@users.noreply.github.com", gitName) 55 | } 56 | } 57 | 58 | func GetName() string { 59 | return gitName 60 | } 61 | 62 | func GetEmail() string { 63 | return gitEmail 64 | } 65 | 66 | func GenerateCloneURL(repositoryFullName string) string { 67 | return fmt.Sprintf("https://%s:%s@github.com/%s.git", username, password, repositoryFullName) 68 | } 69 | 70 | func Exists(repositoryPath string) bool { 71 | _, err := os.Stat(repositoryPath) 72 | return !os.IsNotExist(err) 73 | } 74 | 75 | func GetRepositoryFilePath(name string) string { 76 | return path.Join(repoParentDir, name) 77 | } 78 | 79 | // Clone executes a git clone command on the system. It returns the path to the repository on the system. 80 | func Clone(repositoryUrl string) (string, error) { 81 | orgName := extractOrgFromURL(repositoryUrl) 82 | repoName := extractRepoNameFromURL(repositoryUrl) 83 | repositoryPath := path.Join(repoParentDir, orgName, repoName) 84 | 85 | log.Println("git.clone.started:", repositoryPath) 86 | 87 | cmd := exec.Command("git", "clone", repositoryUrl, repositoryPath) 88 | if err := cmd.Run(); err != nil { 89 | log.Println("git.clone.failed:", repositoryPath, err.Error()) 90 | return "", err 91 | } 92 | 93 | log.Println("git.clone.finished:", repositoryPath) 94 | 95 | return repositoryPath, nil 96 | } 97 | 98 | // Calls git fetch inside repository path 99 | func Fetch(repositoryPath string) error { 100 | log.Println("git.fetch.started:", repositoryPath) 101 | 102 | cmd := exec.Command("git", "fetch", "origin") 103 | cmd.Dir = path.Join(repositoryPath) 104 | if err := cmd.Run(); err != nil { 105 | log.Println("git.fetch.failed:", repositoryPath, err.Error()) 106 | return err 107 | } 108 | 109 | log.Println("git.fetch.finished:", repositoryPath) 110 | 111 | return nil 112 | } 113 | 114 | // Checks out a given git ref inside repository path 115 | func Checkout(repositoryPath, gitRef string) error { 116 | log.Println("git.checkout.started:", repositoryPath, gitRef) 117 | 118 | cmd := exec.Command("git", "checkout", gitRef) 119 | cmd.Dir = path.Join(repositoryPath) 120 | if err := cmd.Run(); err != nil { 121 | log.Println("git.checkout.failed:", repositoryPath, err.Error()) 122 | return err 123 | } 124 | 125 | log.Println("git.checkout.finished:", repositoryPath, gitRef) 126 | 127 | return nil 128 | } 129 | 130 | // Does hard reset inside repository path 131 | func Reset(repositoryPath, branch string) error { 132 | log.Println("git.reset.started:", repositoryPath, branch) 133 | 134 | cmd := exec.Command("git", "reset", "--hard", branch) 135 | cmd.Dir = path.Join(repositoryPath) 136 | if err := cmd.Run(); err != nil { 137 | log.Println("git.reset.failed:", repositoryPath, err.Error()) 138 | return err 139 | } 140 | 141 | log.Println("git.reset.finished:", repositoryPath, branch) 142 | 143 | return nil 144 | } 145 | 146 | // Rebases branch with baseBranch inside repository path 147 | func Rebase(repositoryPath, baseBranch string) error { 148 | cmdOutput := &Output{Buffer: ""} 149 | log.Println("git.rebase.started:", repositoryPath, baseBranch) 150 | 151 | cmd := exec.Command("git", "rebase", baseBranch) 152 | cmd.Dir = path.Join(repositoryPath) 153 | cmd.Stderr = cmdOutput 154 | cmd.Stdout = cmdOutput 155 | 156 | if err := cmd.Run(); err != nil { 157 | log.Printf("git.rebase.failed repo: %s, err: %s \n", repositoryPath, err.Error()) 158 | 159 | log.Printf("git.rebase.abort.started repo: %s, err: %s, stderr: %s \n", repositoryPath, err.Error(), cmdOutput.String()) 160 | 161 | abortCmd := exec.Command("git", "rebase", "--abort") 162 | abortCmd.Dir = path.Join(repositoryPath) 163 | 164 | if err := abortCmd.Run(); err != nil { 165 | log.Println("git.rebase.abort.failed:", repositoryPath) 166 | } else { 167 | log.Println("git.rebase.abort.finished:", repositoryPath) 168 | } 169 | 170 | return err 171 | } 172 | 173 | log.Println("git.rebase.finished:", repositoryPath, baseBranch) 174 | 175 | return nil 176 | } 177 | 178 | // Light wrapper around os/exec.Command + logging 179 | func Prune(repositoryPath string) error { 180 | log.Println("git.remote.prune.started:", repositoryPath) 181 | 182 | cmd := exec.Command("git", "remote", "prune", "origin") 183 | cmd.Dir = path.Join(repositoryPath) 184 | if err := cmd.Run(); err != nil { 185 | log.Println("git.remote.prune.failed:", repositoryPath, err.Error()) 186 | return err 187 | } 188 | 189 | log.Println("git.remote.prune.finished:", repositoryPath) 190 | 191 | return nil 192 | } 193 | 194 | // Pushes branch back to origin 195 | func Push(repositoryPath, branch string) error { 196 | log.Println("git.push.started:", repositoryPath, branch) 197 | 198 | cmd := exec.Command("git", "push", "--force", "origin", branch) 199 | cmd.Dir = path.Join(repositoryPath) 200 | if err := cmd.Run(); err != nil { 201 | log.Println("git.push.failed:", repositoryPath, err.Error()) 202 | return err 203 | } 204 | 205 | log.Println("git.push.finished:", repositoryPath, branch) 206 | 207 | return nil 208 | } 209 | 210 | func Config(repositoryPath, configKey, configValue string) error { 211 | log.Printf("git.config.started: %s=%s\n", configKey, configValue) 212 | 213 | cmd := exec.Command("git", "config", configKey, configValue) 214 | cmd.Dir = path.Join(repositoryPath) 215 | 216 | if err := cmd.Run(); err != nil { 217 | log.Println("git.config.failed:", err.Error()) 218 | return err 219 | } 220 | 221 | log.Printf("git.config.finished: %s=%s\n", configKey, configValue) 222 | return nil 223 | } 224 | 225 | func extractOrgFromURL(githubURL string) string { 226 | splitBySlash := strings.Split(githubURL, "/") 227 | return splitBySlash[len(splitBySlash)-2] 228 | } 229 | 230 | func extractRepoNameFromURL(githubURL string) string { 231 | splitBySlash := strings.Split(githubURL, "/") 232 | repoNameWithExt := splitBySlash[len(splitBySlash)-1] 233 | return strings.Split(repoNameWithExt, ".")[0] 234 | } 235 | -------------------------------------------------------------------------------- /git/git_test.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import "testing" 4 | 5 | func TestExtractOrgFromURLWithGoodURL(t *testing.T) { 6 | expected := "chrisledet" 7 | result := extractOrgFromURL("git://github.com/chrisledet/dotfiles.git") 8 | 9 | if result != expected { 10 | t.Errorf("got %s, want: %s \n", result, expected) 11 | } 12 | } 13 | 14 | func TestExtractOrgFromURLWithBadURL(t *testing.T) { 15 | expected := "chrisledet" 16 | result := extractOrgFromURL("github.com/chrisledet/dotfiles.git") 17 | 18 | if result != expected { 19 | t.Errorf("got %s, want: %s \n", result, expected) 20 | } 21 | } 22 | 23 | func TestExtractRepoNameFromURLWithGoodURL(t *testing.T) { 24 | expected := "dotfiles" 25 | result := extractRepoNameFromURL("git://github.com/chrisledet/dotfiles.git") 26 | 27 | if result != expected { 28 | t.Errorf("got %s, want: %s \n", result, expected) 29 | } 30 | } 31 | 32 | func TestExtractRepoNameFromURLWithBadURL(t *testing.T) { 33 | expected := "dotfiles" 34 | result := extractRepoNameFromURL("github.com/chrisledet/dotfiles.git") 35 | 36 | if result != expected { 37 | t.Errorf("got %s, want: %s \n", result, expected) 38 | } 39 | } 40 | 41 | func TestExtractRepoNameFromURLWithoutExt(t *testing.T) { 42 | expected := "dotfiles" 43 | result := extractRepoNameFromURL("github.com/chrisledet/dotfiles") 44 | 45 | if result != expected { 46 | t.Errorf("got %s, want: %s \n", result, expected) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /github/comment.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | type Comment struct { 4 | Body string `json:"body"` 5 | User User `json:"user"` 6 | } 7 | -------------------------------------------------------------------------------- /github/error.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | type Error struct { 4 | Message string `json:"message"` 5 | InvalidResources []InvalidResource 6 | } 7 | 8 | // GitHub API Reference: 9 | // Error Name Description 10 | // ---------- ----------- 11 | // missing This means a resource does not exist. 12 | // missing_field This means a required field on a resource has not been set. 13 | // invalid This means the formatting of a field is invalid. The documentation for that resource should be able to give you more specific information. 14 | // already_exists This means another resource has the same value as this field. This can happen in resources that must have some unique key (such as Label names). 15 | 16 | type InvalidResource struct { 17 | Resource string `json:"resource"` 18 | Field string `json:"field"` 19 | Code string `json:"code"` 20 | } 21 | -------------------------------------------------------------------------------- /github/event.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import "fmt" 4 | 5 | type Event struct { 6 | Action string `json:"action"` 7 | Issue Issue `json:"issue"` 8 | PullRequest Issue `json:"pull_request"` 9 | Comment Comment `json:"comment"` 10 | Repository Repository `json:"repository"` 11 | } 12 | 13 | func (e Event) String() string { 14 | return fmt.Sprintf("Title: %s, HEAD: %s, Base: %s,", e.Issue.Title, e.PullRequest.Head.Ref, e.PullRequest.Base.Ref) 15 | } 16 | -------------------------------------------------------------------------------- /github/git_ref.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | type GitRef struct { 4 | Ref string `json:"ref"` 5 | Sha string `json:"sha"` 6 | Repository Repository `json:"repo"` 7 | } 8 | -------------------------------------------------------------------------------- /github/github.go: -------------------------------------------------------------------------------- 1 | // Package github provides a simple client for the GitHub API 2 | package github 3 | 4 | import ( 5 | "net/http" 6 | "os" 7 | "strings" 8 | ) 9 | 10 | const ( 11 | mediaType = "application/vnd.github.v3+json" 12 | contentType = "application/json" 13 | agent = "rebasebot" 14 | ) 15 | 16 | var ( 17 | username string 18 | password string 19 | signature string 20 | httpClient = &http.Client{} 21 | ) 22 | 23 | func init() { 24 | username = os.Getenv("GITHUB_USERNAME") 25 | password = os.Getenv("GITHUB_PASSWORD") 26 | signature = os.Getenv("SECRET") 27 | } 28 | 29 | // Returns a request set up for the GitHub API 30 | func NewGitHubRequest(path string) *http.Request { 31 | requestUrl := "https://api.github.com" + path 32 | request, _ := http.NewRequest("GET", requestUrl, nil) 33 | request.SetBasicAuth(username, password) 34 | request.Header.Set("Accept", mediaType) 35 | request.Header.Set("Content-Type", contentType) 36 | request.Header.Set("User-Agent", agent) 37 | 38 | return request 39 | } 40 | 41 | // Check to see if logged in user was mentioned in comment 42 | func WasMentioned(c Comment) bool { 43 | return strings.Contains(c.Body, "@"+username) 44 | } 45 | -------------------------------------------------------------------------------- /github/issue.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | type Issue struct { 4 | Body string `json:"body"` 5 | State string `json:"state"` 6 | Title string `json:"title"` 7 | Number int `json:"number"` 8 | Head GitRef `json:"head"` 9 | Base GitRef `json:"base"` 10 | Repository Repository `json:"repository"` 11 | } 12 | -------------------------------------------------------------------------------- /github/pull_request.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io/ioutil" 8 | "log" 9 | "net/http" 10 | ) 11 | 12 | type PullRequest struct { 13 | Body string `json:"body"` 14 | State string `json:"state"` 15 | Title string `json:"title"` 16 | Number int `json:"number"` 17 | Head GitRef `json:"head"` 18 | Base GitRef `json:"base"` 19 | } 20 | 21 | // PostComment posts a new comment on pull request via GitHub API 22 | func (pr PullRequest) PostComment(msg string) (Comment, error) { 23 | log.Println("github.pr.comments.create.started") 24 | 25 | var err error 26 | var comment Comment 27 | 28 | createCommentPath := fmt.Sprintf("/repos/%s/issues/%d/comments", pr.Base.Repository.FullName, pr.Number) 29 | requestBodyAsBytes := []byte(fmt.Sprintf(`{"body":"%s"}`, msg)) 30 | requestBody := ioutil.NopCloser(bytes.NewReader(requestBodyAsBytes)) 31 | 32 | request := NewGitHubRequest(createCommentPath) 33 | request.Method = "POST" 34 | request.Header.Set("ContentLength", string(len(requestBodyAsBytes))) 35 | request.Body = requestBody 36 | response, err := httpClient.Do(request) 37 | 38 | var responseBodyAsBytes []byte 39 | 40 | if err != nil { 41 | log.Println("github.pr.comments.create.failed error:", err.Error()) 42 | return comment, err 43 | } 44 | 45 | defer response.Body.Close() 46 | 47 | responseBodyAsBytes, err = ioutil.ReadAll(response.Body) 48 | if err != nil { 49 | log.Println("github.pr.comments.create.failed error:", err) 50 | return comment, err 51 | } 52 | 53 | if response.StatusCode != http.StatusCreated { 54 | apiError := new(Error) 55 | json.Unmarshal(responseBodyAsBytes, apiError) 56 | log.Printf("github.pr.comments.create.failed status: %d, msg: %s \n", response.StatusCode, apiError.Message) 57 | return comment, err 58 | } 59 | 60 | json.Unmarshal(responseBodyAsBytes, &comment) 61 | 62 | log.Println("github.pr.comments.create.completed number:", pr.Number) 63 | return comment, nil 64 | } 65 | -------------------------------------------------------------------------------- /github/repository.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io/ioutil" 7 | "log" 8 | "net/http" 9 | ) 10 | 11 | type Repository struct { 12 | FullName string `json:"full_name"` 13 | Name string `json:"name"` 14 | GitUrl string `json:"git_url"` 15 | SshUrl string `json:"ssh_url"` 16 | Owner User `json:"owner"` 17 | } 18 | 19 | func (r Repository) FindPR(number int) (*PullRequest, error) { 20 | var pr PullRequest 21 | 22 | log.Println("github.find_pr.started") 23 | 24 | path := fmt.Sprintf("/repos/%s/pulls/%d", r.FullName, number) 25 | request := NewGitHubRequest(path) 26 | response, err := httpClient.Do(request) 27 | 28 | defer response.Body.Close() 29 | 30 | if err != nil { 31 | log.Println("github.find_pr.failed error: ", err) 32 | return &pr, err 33 | } 34 | 35 | if response.StatusCode != http.StatusOK { 36 | log.Println("github.find_pr.failed status: ", response.StatusCode) 37 | return &pr, err 38 | } 39 | 40 | body, err := ioutil.ReadAll(response.Body) 41 | if err != nil { 42 | log.Println("github.find_pr.failed error:", err) 43 | return &pr, err 44 | } 45 | 46 | if err := json.Unmarshal(body, &pr); err != nil { 47 | log.Println("github.find_pr.failed error:", err) 48 | return &pr, err 49 | } 50 | 51 | log.Printf("github.find_pr.completed number: %d\n", pr.Number) 52 | 53 | return &pr, nil 54 | } 55 | -------------------------------------------------------------------------------- /github/user.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | type User struct { 4 | ID int `json:"id"` 5 | Login string `json:"login"` 6 | } 7 | -------------------------------------------------------------------------------- /http/helper.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "crypto/md5" 5 | "encoding/hex" 6 | "io" 7 | "log" 8 | "net/http" 9 | "strings" 10 | "time" 11 | ) 12 | 13 | func logRequest(r *http.Request) { 14 | ip := ipAddr(r.RemoteAddr) 15 | 16 | log.Printf( 17 | "http.request.received method: %s, path: %s, ip: %s, client: %s\n", 18 | r.Method, 19 | r.RequestURI, 20 | ip, 21 | generateClientID(ip), 22 | ) 23 | } 24 | 25 | func logResponse(r *http.Request, status int, startedAt time.Time) { 26 | ip := ipAddr(r.RemoteAddr) 27 | 28 | log.Printf( 29 | "http.request.finished method: %s, path: %s, ip: %s, client: %s, status: %d, time: %v\n", 30 | r.Method, 31 | r.RequestURI, 32 | ip, 33 | generateClientID(ip), 34 | status, 35 | time.Now().Sub(startedAt), 36 | ) 37 | } 38 | 39 | func ipAddr(remoteAddr string) string { 40 | return strings.Split(remoteAddr, ":")[0] 41 | } 42 | 43 | func generateClientID(key string) string { 44 | md5Hash := md5.New() 45 | io.WriteString(md5Hash, key) 46 | return hex.EncodeToString(md5Hash.Sum(nil)) 47 | } 48 | -------------------------------------------------------------------------------- /http/helper_test.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestGenerateClientID(t *testing.T) { 8 | str1 := generateClientID("str1") 9 | str2 := generateClientID("str1") 10 | 11 | if str1 != str2 { 12 | t.Error("Did not generate same value for key") 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /http/rebase.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "crypto/hmac" 5 | "crypto/sha1" 6 | "encoding/hex" 7 | "encoding/json" 8 | "io/ioutil" 9 | "log" 10 | "net/http" 11 | "os" 12 | "time" 13 | "strings" 14 | 15 | "github.com/chrisledet/rebasebot/github" 16 | "github.com/chrisledet/rebasebot/integrations" 17 | ) 18 | 19 | func Rebase(w http.ResponseWriter, r *http.Request) { 20 | receivedAt := time.Now() 21 | logRequest(r) 22 | 23 | if r.Method != "POST" { 24 | w.WriteHeader(http.StatusNotFound) 25 | logResponse(r, http.StatusNotFound, receivedAt) 26 | return 27 | } 28 | 29 | var event github.Event 30 | var responseStatus = http.StatusCreated 31 | 32 | body, err := ioutil.ReadAll(r.Body) 33 | if err != nil { 34 | responseStatus = http.StatusInternalServerError 35 | } 36 | 37 | if !isVerifiedRequest(r.Header, body) { 38 | w.WriteHeader(http.StatusUnauthorized) 39 | logResponse(r, http.StatusUnauthorized, receivedAt) 40 | return 41 | } 42 | 43 | if err := json.Unmarshal(body, &event); err != nil { 44 | responseStatus = http.StatusBadRequest 45 | log.Printf("http.request.body.parse_failed: %s\n", err.Error()) 46 | return 47 | } 48 | 49 | go func() { 50 | if !(strings.Compare(event.Action, "created") == 0 && github.WasMentioned(event.Comment) && strings.Contains(event.Comment.Body, "rebase")) { 51 | return 52 | } 53 | 54 | log.Printf("bot.rebase.started, name: %s\n", event.Repository.FullName) 55 | defer log.Printf("bot.rebase.finished: %s\n", event.Repository.FullName) 56 | 57 | pullRequest, err := event.Repository.FindPR(event.Issue.Number) 58 | if err == nil { 59 | integrations.GitRebase(pullRequest) 60 | } 61 | }() 62 | 63 | w.WriteHeader(responseStatus) 64 | logResponse(r, responseStatus, receivedAt) 65 | } 66 | 67 | func isVerifiedRequest(header http.Header, body []byte) bool { 68 | serverSignature := os.Getenv("SECRET") 69 | requestSignature := header.Get("X-Hub-Signature") 70 | 71 | // when not set up with a secret 72 | if len(serverSignature) < 1 { 73 | log.Println("http.request.signature.verification.skipped") 74 | return true 75 | } 76 | 77 | log.Println("http.request.signature.verification.started") 78 | 79 | if len(requestSignature) < 1 { 80 | log.Println("http.request.signature.verification.failed", "missing X-Hub-Signature header") 81 | return false 82 | } 83 | 84 | mac := hmac.New(sha1.New, []byte(serverSignature)) 85 | mac.Write(body) 86 | expectedMAC := mac.Sum(nil) 87 | expectedSignature := "sha1=" + hex.EncodeToString(expectedMAC) 88 | signatureMatched := hmac.Equal([]byte(expectedSignature), []byte(requestSignature)) 89 | 90 | if signatureMatched { 91 | log.Println("http.request.signature.verification.passed") 92 | } else { 93 | log.Println("http.request.signature.verification.failed") 94 | } 95 | 96 | return signatureMatched 97 | } 98 | -------------------------------------------------------------------------------- /http/status.go: -------------------------------------------------------------------------------- 1 | // Package http implements handlers used by rebasebot http server 2 | package http 3 | 4 | import ( 5 | "fmt" 6 | "net/http" 7 | "time" 8 | ) 9 | 10 | func Status(w http.ResponseWriter, r *http.Request) { 11 | receivedAt := time.Now() 12 | logRequest(r) 13 | 14 | w.WriteHeader(http.StatusOK) 15 | fmt.Fprintf(w, "OK\n") 16 | 17 | logResponse(r, http.StatusOK, receivedAt) 18 | } 19 | -------------------------------------------------------------------------------- /integrations/git.go: -------------------------------------------------------------------------------- 1 | package integrations 2 | 3 | import ( 4 | "path" 5 | 6 | "github.com/chrisledet/rebasebot/git" 7 | "github.com/chrisledet/rebasebot/github" 8 | ) 9 | 10 | // Ties the git operations together to perform a branch rebase 11 | func GitRebase(pr *github.PullRequest) error { 12 | 13 | filepath := git.GetRepositoryFilePath(pr.Head.Repository.FullName) 14 | remoteRepositoryURL := git.GenerateCloneURL(pr.Head.Repository.FullName) 15 | 16 | if !git.Exists(filepath) { 17 | if _, err := git.Clone(remoteRepositoryURL); err != nil { 18 | pr.PostComment("I could not pull " + pr.Head.Repository.FullName + " from GitHub.") 19 | return err 20 | } 21 | } 22 | 23 | if err := git.Fetch(filepath); err != nil { 24 | git.Prune(filepath) 25 | pr.PostComment("I could not fetch the latest changes from GitHub. Please try again in a few minutes.") 26 | return err 27 | } 28 | 29 | if err := git.Checkout(filepath, pr.Head.Ref); err != nil { 30 | pr.PostComment("I could not checkout " + pr.Head.Ref + " locally.") 31 | return err 32 | } 33 | 34 | if err := git.Reset(filepath, path.Join("origin", pr.Head.Ref)); err != nil { 35 | pr.PostComment("I could not checkout " + pr.Head.Ref + " locally.") 36 | return err 37 | } 38 | 39 | if err := git.Config(filepath, "user.name", git.GetName()); err != nil { 40 | pr.PostComment("I could run git config for user.name on the server.") 41 | return err 42 | } 43 | 44 | if err := git.Config(filepath, "user.email", git.GetEmail()); err != nil { 45 | pr.PostComment("I could run git config for user.email on the server.") 46 | return err 47 | } 48 | 49 | if err := git.Rebase(filepath, path.Join("origin", pr.Base.Ref)); err != nil { 50 | pr.PostComment("I could not rebase " + pr.Head.Ref + " with " + pr.Base.Ref + ". There are conflicts.") 51 | return err 52 | } 53 | 54 | if err := git.Push(filepath, pr.Head.Ref); err != nil { 55 | pr.PostComment("I could not push the changes to " + pr.Base.Ref + ".") 56 | return err 57 | } 58 | 59 | pr.PostComment("I just pushed up the changes, enjoy!") 60 | return nil 61 | } 62 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "net/http" 6 | "os" 7 | 8 | "github.com/chrisledet/rebasebot/config" 9 | _http "github.com/chrisledet/rebasebot/http" 10 | ) 11 | 12 | const ( 13 | Version = "0.1.2" 14 | ) 15 | 16 | var ( 17 | conf *config.Config 18 | ) 19 | 20 | func init() { 21 | var err error 22 | 23 | switch os.Getenv("DEV") { 24 | case "true": 25 | conf, err = config.NewDevConfig() 26 | default: 27 | conf, err = config.NewConfig() 28 | } 29 | 30 | if err != nil { 31 | log.Fatalf("server.down err: %s\n", err.Error()) 32 | } 33 | } 34 | 35 | func main() { 36 | 37 | http.HandleFunc("/rebase", _http.Rebase) 38 | http.HandleFunc("/status", _http.Status) 39 | 40 | log.Printf("server.up: 0.0.0.0:%s version: %s\n", conf.Port, Version) 41 | 42 | err := http.ListenAndServe(":"+conf.Port, nil) 43 | if err != nil { 44 | log.Fatalf("server.down: %s\n", err) 45 | } 46 | } 47 | --------------------------------------------------------------------------------