├── .dockerignore ├── .github ├── dependabot.yml └── workflows │ ├── codeql.yml │ └── push-build-test-on-push.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── Makefile ├── Procfile ├── README.md ├── affinity ├── affinity.go ├── affinity_test.go ├── handler.go ├── repo.go ├── team.go └── team_test.go ├── auth └── auth.go ├── autopull └── autopull.go ├── chlog ├── History.markdown ├── chlog.go ├── close_milestone_on_release.go ├── create_release_on_tag.go ├── create_release_on_tag_test.go ├── history_contents.enc ├── merge_and_label.go └── merge_and_label_test.go ├── cmd ├── check-for-outdated-dependencies │ ├── heroku.go │ └── main.go ├── freeze-ancient-issues │ ├── heroku.go │ └── main.go ├── jekyllbot │ ├── heroku.go │ └── jekyllbot.go ├── mark-and-sweep-stale-issues │ ├── heroku.go │ └── mark_and_sweep_stale_issues.go ├── nudge-maintainers-to-release │ └── main.go ├── unearth │ ├── heroku.go │ └── unearth.go └── unify-labels │ ├── heroku.go │ └── unify_labels.go ├── common ├── common.go └── common_test.go ├── ctx ├── context.go ├── github.go ├── issue_ref.go ├── repo_ref.go ├── rubygems.go └── statsd.go ├── dependencies ├── dependencies.go ├── dependency.go ├── github.go └── ruby_dependency.go ├── freeze └── freeze.go ├── go.mod ├── go.sum ├── hooks ├── event_types.go ├── global_handler.go ├── handler.go ├── hooks.go └── repo.go ├── jekyll ├── deprecate │ └── deprecate.go ├── issuecomment │ ├── issuecomment.go │ ├── pending_feedback_unlabeler.go │ └── stale_unlabeler.go ├── jekyll.go └── repos.go ├── labeler ├── github.go ├── has_pull_request.go ├── has_pull_request_test.go ├── labeler.go └── pending_rebase.go ├── lgtm ├── lgtm.go ├── lgtm_test.go ├── server_test.go ├── status_cache.go ├── status_cache_test.go ├── status_info.go └── status_info_test.go ├── releases └── releases.go ├── search └── github.go ├── sentry └── sentry.go ├── stale ├── stale.go └── stale_test.go └── travis ├── failing_fmt_build.go └── failing_fmt_build_test.go /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | vendor/ 3 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: gomod 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "11:00" 8 | open-pull-requests-limit: 99 9 | reviewers: 10 | - parkr 11 | - package-ecosystem: docker 12 | directory: "/" 13 | schedule: 14 | interval: daily 15 | time: "11:00" 16 | open-pull-requests-limit: 99 17 | reviewers: 18 | - parkr 19 | - package-ecosystem: github-actions 20 | directory: "/" 21 | schedule: 22 | interval: daily 23 | time: "11:00" 24 | open-pull-requests-limit: 99 25 | reviewers: 26 | - parkr 27 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [ 'main' ] 6 | pull_request: 7 | # The branches below must be a subset of the branches above 8 | branches: [ 'main' ] 9 | schedule: 10 | - cron: '34 12 * * 1' 11 | 12 | jobs: 13 | analyze: 14 | name: Analyze 15 | runs-on: ubuntu-latest 16 | permissions: 17 | actions: read 18 | contents: read 19 | security-events: write 20 | 21 | strategy: 22 | fail-fast: false 23 | matrix: 24 | language: [ 'go' ] 25 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 26 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 27 | 28 | steps: 29 | - name: Checkout repository 30 | uses: actions/checkout@v3 31 | 32 | # Initializes the CodeQL tools for scanning. 33 | - name: Initialize CodeQL 34 | uses: github/codeql-action/init@v2 35 | with: 36 | languages: ${{ matrix.language }} 37 | # If you wish to specify custom queries, you can do so here or in a config file. 38 | # By default, queries listed here will override any specified in a config file. 39 | # Prefix the list here with "+" to use these queries and those in the config file. 40 | 41 | # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 42 | queries: +security-and-quality 43 | 44 | 45 | # Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java). 46 | # If this step fails, then you should remove it and run the build manually (see below) 47 | - name: Autobuild 48 | uses: github/codeql-action/autobuild@v2 49 | 50 | # ℹ️ Command-line programs to run using the OS shell. 51 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 52 | 53 | # If the Autobuild fails above, remove it and uncomment the following three lines. 54 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 55 | 56 | # - run: | 57 | # echo "Run, Build Application using script" 58 | # ./location_of_script_within_repo/buildscript.sh 59 | 60 | - name: Perform CodeQL Analysis 61 | uses: github/codeql-action/analyze@v2 62 | with: 63 | category: "/language:${{matrix.language}}" 64 | -------------------------------------------------------------------------------- /.github/workflows/push-build-test-on-push.yml: -------------------------------------------------------------------------------- 1 | on: push 2 | name: Build & test 3 | jobs: 4 | buildAndTest: 5 | name: Build & Test 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: actions/checkout@master 9 | - name: Build & Test 10 | uses: parkr/actions/docker-make@main 11 | with: 12 | args: docker-build -e REV=${{ github.sha }} 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled Object files, Static and Dynamic libs (Shared Objects) 2 | *.o 3 | *.a 4 | *.so 5 | 6 | # Folders 7 | _obj 8 | _test 9 | 10 | # Architecture specific extensions/prefixes 11 | *.[568vq] 12 | [568vq].out 13 | 14 | *.cgo1.go 15 | *.cgo2.c 16 | _cgo_defun.c 17 | _cgo_gotypes.go 18 | _cgo_export.* 19 | 20 | _testmain.go 21 | 22 | *.exe 23 | *.test 24 | *.prof 25 | 26 | bin/* 27 | .env 28 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:latest as builder 2 | WORKDIR /app/auto-reply 3 | RUN set -ex \ 4 | && apt-get update -y \ 5 | && apt-get upgrade -y \ 6 | && apt-get install make 7 | COPY go.* /app/auto-reply/ 8 | RUN go mod download 9 | COPY . . 10 | RUN make -j10 cibuild 11 | 12 | FROM scratch 13 | COPY --from=builder bin/ /bin/. 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015, Parker Moore 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 10 | * Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | 14 | * Neither the name of auto-reply nor the names of its 15 | contributors may be used to endorse or promote products derived from 16 | this software without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 19 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 20 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 22 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 23 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 24 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 25 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 26 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 27 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | REV:=$(shell git rev-parse HEAD) 2 | ROOT_PKG=github.com/parkr/auto-reply 3 | BINARIES = bin/check-for-outdated-dependencies \ 4 | bin/jekyllbot \ 5 | bin/mark-and-sweep-stale-issues \ 6 | bin/nudge-maintainers-to-release \ 7 | bin/unearth \ 8 | bin/unify-labels 9 | 10 | .PHONY: all 11 | all: fmt build test 12 | 13 | .PHONY: cibuild 14 | cibuild: fmt build test 15 | 16 | .PHONY: fmt 17 | fmt: 18 | find . | grep -v '^vendor' | grep '\.go$$' | xargs gofmt -s -l -w | sed -e 's/^/Fixed /' 19 | go list $(ROOT_PKG)/... | xargs go fix 20 | go list $(ROOT_PKG)/... | xargs go vet 21 | 22 | .PHONY: $(BINARIES) 23 | $(BINARIES): clean 24 | go build -o ./$@ ./$(patsubst bin/%,cmd/%,$@) 25 | 26 | .PHONY: build 27 | build: clean $(BINARIES) 28 | ls -lh bin/ 29 | 30 | .PHONY: test 31 | test: 32 | go test github.com/parkr/auto-reply/... 33 | 34 | .PHONY: server 35 | server: build 36 | source .env && ./bin/auto-reply 37 | 38 | .PHONY: unearth 39 | unearth: build 40 | source .env && ./bin/unearth 41 | 42 | .PHONY: mark-and-sweep 43 | mark-and-sweep: build 44 | source .env && ./bin/mark-and-sweep-stale-issues 45 | 46 | .PHONY: clean 47 | clean: 48 | rm -rf bin 49 | 50 | .PHONY: docker-build 51 | docker-build: 52 | docker build -t auto-reply:$(REV) . 53 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: jekyllbot -port=$PORT 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # auto-reply 2 | 3 | An open source gardener. This is a technology for powering GitHub bots. It's really rough around the edges but it currently powers [@jekyllbot](https://github.com/jekyllbot). 4 | 5 | [![Build Status](https://travis-ci.org/parkr/auto-reply.svg?branch=master)](https://travis-ci.org/parkr/auto-reply) 6 | 7 | ## Configuring 8 | 9 | If you want to configure a secret to validate your payload from GitHub, 10 | then set it as the environment variable `GITHUB_WEBHOOK_SECRET`. This is 11 | the same value you enter in the web interface when setting up the "Secret" 12 | for your webhook. 13 | 14 | I could use [your thoughts on this!](https://github.com/parkr/auto-reply/issues/4) Currently, it's a hodge-podge. The documentation for each package will provide more details on this. Currently we have the following packages, with varying levels of configuration: 15 | 16 | - `affinity` – assigns issues based on team mentions and those team captains. See [Jekyll's docs for more info.](https://github.com/jekyll/jekyll/blob/master/docs/affinity-team-captain.md) 17 | - `autopull` – detects pushes to branches which start with `pull/` and automatically creates a PR for them 18 | - `chlog` – creates GitHub releases when a new tag is pushed, and powers "@jekyllbot: merge (+category)" 19 | - `jekyll/deprecate` – comments on and closes issues to issues on certain repos with a per-repo stock message 20 | - `jekyll/issuecomment` – provides handlers for removing `pending-feedback` and `stale` labels when a comment comes through 21 | - `labeler` – removes `pending-rebase` label when a PR is pushed to and is mergeable (and helper functions for manipulating labels) 22 | - `lgtm` – adds a `jekyllbot/lgtm` CI status and handles `LGTM` counting 23 | 24 | ## Installing 25 | 26 | This is intended for use with servers, so you'd do something like: 27 | 28 | ```go 29 | package main 30 | 31 | import ( 32 | "flag" 33 | "log" 34 | "net/http" 35 | 36 | "github.com/parkr/auto-reply/affinity" 37 | "github.com/parkr/auto-reply/ctx" 38 | "github.com/parkr/auto-reply/hooks" 39 | ) 40 | 41 | var context *ctx.Context 42 | 43 | func main() { 44 | var port string 45 | flag.StringVar(&port, "port", "8080", "The port to serve to") 46 | flag.Parse() 47 | context = ctx.NewDefaultContext() 48 | 49 | http.HandleFunc("/_ping", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 50 | w.Header().Set("Content-Type", "text/plain") 51 | w.Write([]byte("ok\n")) 52 | })) 53 | 54 | // Add your event handlers. Check out the documentation for the 55 | // github.com/parkr/auto-reply/hooks package to see all supported events. 56 | eventHandlers := hooks.EventHandlerMap{} 57 | 58 | // Build the affinity handler. 59 | aff := &affinity.Handler{} 60 | aff.AddRepo("myorg", "myproject") 61 | aff.AddTeam(context, 123) // @myorg/performance 62 | aff.AddTeam(context, 456) // @myorg/documentation 63 | 64 | // Add the affinity handler's various event handlers to the event handlers map :) 65 | eventHandlers.AddHandler(hooks.IssuesEvent, aff.AssignIssueToAffinityTeamCaptain) 66 | eventHandlers.AddHandler(hooks.IssueCommentEvent, aff.AssignIssueToAffinityTeamCaptainFromComment) 67 | eventHandlers.AddHandler(hooks.PullRequestEvent, aff.RequestReviewFromAffinityTeamCaptain) 68 | 69 | // Create the webhook handler. GlobalHandler takes the list of event handlers from 70 | // its configuration and fires each of them based on the X-GitHub-Event header from 71 | // the webhook payload. 72 | myOrgHandler := &hooks.GlobalHandler{ 73 | Context: context, 74 | EventHandlers: eventHandlers, 75 | } 76 | http.Handle("/_github/myproject", myOrgHandler) 77 | 78 | log.Printf("Listening on :%s", port) 79 | log.Fatal(http.ListenAndServe(":"+port, nil)) 80 | } 81 | ``` 82 | 83 | ## Writing Custom Handlers 84 | 85 | For now, all you have to do is write a function which satisfies the `hooks.EventHandler` type. At the moment, each handler can accept only **one** type of event. If you want to accept the `issue_comment` event, then you should be able to perform a successful type assertion: 86 | 87 | ```go 88 | func MyIssueCommentHandler(context *ctx.Context, payload interface{}) error { 89 | event, err := payload.(*github.IssueCommentEvent) 90 | if err != nil { 91 | return context.NewError("MyIssueCommentHandler: hm, didn't get an IssueCommentEvent: %v", err) 92 | } 93 | 94 | // Handle your issue comment event in a type-safe way here. 95 | } 96 | ``` 97 | 98 | Then you register that with your project. Taking the two examples above, you'd add `MyIssueCommentHandler` to the `eventHandlers[hooks.IssueCommentEvent]` array: 99 | 100 | ```go 101 | eventHandlers := hooks.EventHandlerMap{} 102 | eventHandlers.AddHandler(hooks.IssueCommentEvent, MyIssueCommentHandler) 103 | ``` 104 | 105 | And it should work! 106 | 107 | ## Optional: Mark-and-sweep Stale Issues 108 | 109 | One big issue we have in Jekyll is "stale" issues, that is, issues which were opened and abandoned after a few months of activity. The code in `cmd/mark-and-sweep-stale-issues` is still Jekyll-specific but I'd love a PR which abstracts out the configuration into a file or something! 110 | 111 | ## License 112 | 113 | This code is licensed under BSD 3-clause as specified in the [LICENSE](LICENSE) file in this repository. 114 | -------------------------------------------------------------------------------- /affinity/affinity.go: -------------------------------------------------------------------------------- 1 | // affinity assigns issues based on team mentions and those team captains. 2 | // The idea is to separate the work of triaging of issues and pull requests 3 | // out to a larger pool of people to make it less of a burden to be involved. 4 | package affinity 5 | 6 | import ( 7 | "fmt" 8 | "strings" 9 | 10 | "github.com/google/go-github/github" 11 | "github.com/parkr/auto-reply/ctx" 12 | ) 13 | 14 | var explanation = `We are utilizing a new workflow in our issues and pull requests. Affinity teams have been setup to allow community members to hear about pull requests that may be interesting to them. When a new issue or pull request comes in, we are asking that the author mention the appropriate affinity team. I then assign a random "team captain" or two to the issue who is in charge of triaging it until it is closed or passing it off to another captain. In order to move forward with this new workflow, we need to know: which of the following teams best fits your issue or contribution?` 15 | 16 | func assignTeamCaptains(context *ctx.Context, handler Handler, body string, assigneeCount int) error { 17 | if context.Issue.IsEmpty() { 18 | context.IncrStat("affinity.error.no_ref", nil) 19 | return context.NewError("assignTeamCaptains: issue reference was not set; bailing") 20 | } 21 | 22 | team, err := findAffinityTeam(body, handler.teams) 23 | if err != nil { 24 | context.IncrStat("affinity.error.no_team", nil) 25 | //return askForAffinityTeam(context, handler.teams) 26 | return context.NewError("%s: no team in the message body; unable to assign", context.Issue) 27 | } 28 | 29 | context.Log("team: %s, excluding: %s", team, context.Issue.Author) 30 | victims := team.RandomCaptainLoginsExcluding(context.Issue.Author, assigneeCount) 31 | if len(victims) == 0 { 32 | context.IncrStat("affinity.error.no_acceptable_captains", nil) 33 | return context.NewError("%s: team captains other than issue author could not be found", context.Issue) 34 | } 35 | context.Log("selected affinity team captains for %s: %q", context.Issue, victims) 36 | _, _, err = context.GitHub.Issues.AddAssignees( 37 | context.Context(), 38 | context.Issue.Owner, 39 | context.Issue.Repo, 40 | context.Issue.Num, 41 | victims, 42 | ) 43 | if err != nil { 44 | context.IncrStat("affinity.error.github_api", nil) 45 | return context.NewError("assignTeamCaptains: problem assigning: %v", err) 46 | } 47 | 48 | context.IncrStat("affinity.success", nil) 49 | context.Log("assignTeamCaptains: assigned %q to %s", victims, context.Issue) 50 | return nil 51 | } 52 | 53 | func requestReviewFromTeamCaptains(context *ctx.Context, handler Handler, body string, assigneeCount int) error { 54 | if context.Issue.IsEmpty() { 55 | context.IncrStat("affinity.error.no_ref", nil) 56 | return context.NewError("requestReviewFromTeamCaptains: issue reference was not set; bailing") 57 | } 58 | 59 | team, err := findAffinityTeam(body, handler.teams) 60 | if err != nil { 61 | context.IncrStat("affinity.error.no_team", nil) 62 | //return askForAffinityTeam(context, handler.teams) 63 | return context.NewError("%s: no team in the message body; unable to assign", context.Issue) 64 | } 65 | 66 | context.Log("team: %s, excluding: %s", team, context.Issue.Author) 67 | victims := team.RandomCaptainLoginsExcluding(context.Issue.Author, assigneeCount) 68 | if len(victims) == 0 { 69 | context.IncrStat("affinity.error.no_acceptable_captains", nil) 70 | return context.NewError("%s: team captains other than issue author could not be found", context.Issue) 71 | } 72 | context.Log("selected affinity team captains for %s: %q", context.Issue, victims) 73 | _, _, err = context.GitHub.PullRequests.RequestReviewers( 74 | context.Context(), 75 | context.Issue.Owner, 76 | context.Issue.Repo, 77 | context.Issue.Num, 78 | github.ReviewersRequest{Reviewers: victims}, 79 | ) 80 | if err != nil { 81 | context.IncrStat("affinity.error.github_api", nil) 82 | return context.NewError("requestReviewFromTeamCaptains: problem assigning: %v", err) 83 | } 84 | 85 | context.IncrStat("affinity.success", nil) 86 | context.Log("requestReviewFromTeamCaptains: requested review from %q on %s", victims, context.Issue) 87 | return nil 88 | } 89 | 90 | func findAffinityTeam(body string, allTeams []Team) (Team, error) { 91 | for _, team := range allTeams { 92 | if strings.Contains(body, team.Mention) { 93 | return team, nil 94 | } 95 | } 96 | return Team{}, fmt.Errorf("findAffinityTeam: no matching team") 97 | } 98 | 99 | func askForAffinityTeam(context *ctx.Context, allTeams []Team) error { 100 | _, _, err := context.GitHub.Issues.CreateComment( 101 | context.Context(), 102 | context.Issue.Owner, 103 | context.Issue.Repo, 104 | context.Issue.Num, 105 | &github.IssueComment{Body: github.String(buildAffinityTeamMessage(context, allTeams))}, 106 | ) 107 | if err != nil { 108 | return context.NewError("askForAffinityTeam: could not leave comment: %v", err) 109 | } 110 | return nil 111 | } 112 | 113 | func buildAffinityTeamMessage(context *ctx.Context, allTeams []Team) string { 114 | var prefix string 115 | if context.Issue.Author != "" { 116 | prefix = fmt.Sprintf("Hey, @%s!", context.Issue.Author) 117 | } else { 118 | prefix = "Hello!" 119 | } 120 | 121 | teams := []string{} 122 | for _, team := range allTeams { 123 | teams = append(teams, fmt.Sprintf( 124 | "- `%s` – %s", 125 | team.Mention, team.Description, 126 | )) 127 | } 128 | 129 | return fmt.Sprintf( 130 | "%s %s\n\n%s\n\nMention one of these teams in a comment below and we'll get this sorted. Thanks!", 131 | prefix, explanation, strings.Join(teams, "\n"), 132 | ) 133 | } 134 | 135 | func usersByLogin(users []*github.User) []string { 136 | logins := []string{} 137 | for _, user := range users { 138 | logins = append(logins, *user.Login) 139 | } 140 | return logins 141 | } 142 | -------------------------------------------------------------------------------- /affinity/affinity_test.go: -------------------------------------------------------------------------------- 1 | package affinity 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | var exampleLongComment = `On the site documentation section, links to documentation sections always point to the jekyllrb.com website, this means that users testing changes might get confused because they will see the official external website page instead of their local website upon clicking those links. 10 | 11 | 12 | **Please check if this change doesn't break the official website on https://jekyllrb.com before accepting the pull request.** 13 | 14 | ---------- 15 | 16 | @jekyll/documentation` 17 | 18 | func TestFindAffinityTeam(t *testing.T) { 19 | allTeams := []Team{ 20 | {ID: 456, Mention: "@jekyll/documentation"}, 21 | {ID: 789, Mention: "@jekyll/ecosystem"}, 22 | {ID: 101, Mention: "@jekyll/performance"}, 23 | {ID: 213, Mention: "@jekyll/stability"}, 24 | {ID: 141, Mention: "@jekyll/windows"}, 25 | {ID: 123, Mention: "@jekyll/build"}, 26 | } 27 | 28 | examples := []struct { 29 | body string 30 | matchingTeamID int64 31 | }{ 32 | {exampleLongComment, 456}, 33 | {"@jekyll/documentation @jekyll/build", 456}, 34 | {"@jekyll/windows @jekyll/documentation", 456}, 35 | {"@jekyll/windows", 141}, 36 | } 37 | for _, example := range examples { 38 | matchingTeam, err := findAffinityTeam(example.body, allTeams) 39 | assert.NoError(t, err) 40 | assert.Equal(t, matchingTeam.ID, example.matchingTeamID, 41 | "expected the following to match %d team: `%s`", example.matchingTeamID, example.body) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /affinity/handler.go: -------------------------------------------------------------------------------- 1 | package affinity 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/google/go-github/github" 7 | "github.com/parkr/auto-reply/ctx" 8 | ) 9 | 10 | type Handler struct { 11 | repos []Repo 12 | teams []Team 13 | } 14 | 15 | func (h *Handler) enabledForRepo(owner, name string) bool { 16 | for _, repo := range h.repos { 17 | if repo.Owner == owner && repo.Name == name { 18 | return true 19 | } 20 | } 21 | 22 | return false 23 | } 24 | 25 | func (h *Handler) GetRepos() []Repo { 26 | return h.repos 27 | } 28 | 29 | func (h *Handler) AddRepo(owner, name string) { 30 | if h.repos == nil { 31 | h.repos = []Repo{} 32 | } 33 | 34 | if h.enabledForRepo(owner, name) { 35 | return 36 | } 37 | 38 | h.repos = append(h.repos, Repo{Owner: owner, Name: name}) 39 | } 40 | 41 | func (h *Handler) GetTeams() []Team { 42 | return h.teams 43 | } 44 | 45 | func (h *Handler) AddTeam(context *ctx.Context, teamID int64) error { 46 | if h.teams == nil { 47 | h.teams = []Team{} 48 | } 49 | 50 | if _, err := h.GetTeam(teamID); err == nil { 51 | return nil // already have it! 52 | } 53 | 54 | team, err := NewTeam(context, teamID) 55 | if err != nil { 56 | return err 57 | } 58 | 59 | h.teams = append(h.teams, team) 60 | return nil 61 | } 62 | 63 | func (h *Handler) GetTeam(teamID int64) (Team, error) { 64 | for _, team := range h.teams { 65 | if team.ID == teamID { 66 | return team, nil 67 | } 68 | } 69 | return Team{}, fmt.Errorf("GetTeam: team with ID=%d not found", teamID) 70 | } 71 | 72 | func (h *Handler) RequestReviewFromAffinityTeamCaptains(context *ctx.Context, payload interface{}) error { 73 | event, ok := payload.(*github.PullRequestEvent) 74 | if !ok { 75 | return context.NewError("RequestReviewFromAffinityTeamCaptains: not a pull request event") 76 | } 77 | 78 | context.SetAuthor(*event.Sender.Login) 79 | context.SetIssue(*event.Repo.Owner.Login, *event.Repo.Name, *event.Number) 80 | 81 | if !h.enabledForRepo(context.Issue.Owner, context.Issue.Repo) { 82 | return context.NewError("RequestReviewFromAffinityTeamCaptains: not enabled for %s", context.Issue) 83 | } 84 | 85 | if *event.Action != "opened" { 86 | return context.NewError("RequestReviewFromAffinityTeamCaptains: not an 'opened' PR event") 87 | } 88 | 89 | context.IncrStat("affinity.pull_request", []string{"task:request_review"}) 90 | 91 | return requestReviewFromTeamCaptains(context, *h, *event.PullRequest.Body, 2) 92 | } 93 | 94 | func (h *Handler) AssignPRToAffinityTeamCaptain(context *ctx.Context, payload interface{}) error { 95 | event, ok := payload.(*github.PullRequestEvent) 96 | if !ok { 97 | return context.NewError("AssignPRToAffinityTeamCaptain: not a pull request event") 98 | } 99 | 100 | context.SetAuthor(*event.Sender.Login) 101 | context.SetIssue(*event.Repo.Owner.Login, *event.Repo.Name, *event.Number) 102 | 103 | if !h.enabledForRepo(context.Issue.Owner, context.Issue.Repo) { 104 | return context.NewError("AssignPRToAffinityTeamCaptain: not enabled for %s", context.Issue) 105 | } 106 | 107 | if *event.Action != "opened" { 108 | return context.NewError("AssignPRToAffinityTeamCaptain: not an 'opened' PR event") 109 | } 110 | 111 | if event.PullRequest.Assignee != nil { 112 | context.IncrStat("affinity.error.already_assigned", nil) 113 | return context.NewError("AssignPRToAffinityTeamCaptain: PR already assigned") 114 | } 115 | 116 | if context.GitHubAuthedAs(*event.Sender.Login) { 117 | return fmt.Errorf("bozo. you can't reply to your own comment!") 118 | } 119 | 120 | context.IncrStat("affinity.pull_request", nil) 121 | 122 | return assignTeamCaptains(context, *h, *event.PullRequest.Body, 1) 123 | } 124 | 125 | func (h *Handler) AssignIssueToAffinityTeamCaptain(context *ctx.Context, payload interface{}) error { 126 | event, ok := payload.(*github.IssuesEvent) 127 | if !ok { 128 | return context.NewError("AssignIssueToAffinityTeamCaptain: not an issue event") 129 | } 130 | 131 | context.SetAuthor(*event.Sender.Login) 132 | context.SetIssue(*event.Repo.Owner.Login, *event.Repo.Name, *event.Issue.Number) 133 | 134 | if !h.enabledForRepo(context.Issue.Owner, context.Issue.Repo) { 135 | return context.NewError("AssignIssueToAffinityTeamCaptain: not enabled for %s", context.Issue) 136 | } 137 | 138 | if *event.Action != "opened" { 139 | return context.NewError("AssignIssueToAffinityTeamCaptain: not an 'opened' issue event") 140 | } 141 | 142 | if event.Assignee != nil { 143 | context.IncrStat("affinity.error.already_assigned", nil) 144 | return context.NewError("AssignIssueToAffinityTeamCaptain: issue already assigned") 145 | } 146 | 147 | if context.GitHubAuthedAs(*event.Sender.Login) { 148 | return fmt.Errorf("bozo. you can't reply to your own comment!") 149 | } 150 | 151 | context.IncrStat("affinity.issue", nil) 152 | 153 | return assignTeamCaptains(context, *h, *event.Issue.Body, 1) 154 | } 155 | 156 | func (h *Handler) AssignIssueToAffinityTeamCaptainFromComment(context *ctx.Context, payload interface{}) error { 157 | event, ok := payload.(*github.IssueCommentEvent) 158 | if !ok { 159 | return context.NewError("AssignIssueToAffinityTeamCaptainFromComment: not an issue comment event") 160 | } 161 | 162 | context.SetAuthor(*event.Sender.Login) 163 | context.SetIssue(*event.Repo.Owner.Login, *event.Repo.Name, *event.Issue.Number) 164 | 165 | if !h.enabledForRepo(context.Issue.Owner, context.Issue.Repo) { 166 | return context.NewError("AssignIssueToAffinityTeamCaptainFromComment: not enabled for %s", context.Issue) 167 | } 168 | 169 | if *event.Action == "deleted" { 170 | return context.NewError("AssignIssueToAffinityTeamCaptainFromComment: deleted issue comment event") 171 | } 172 | 173 | if event.Issue.Assignee != nil { 174 | return context.NewError("AssignIssueToAffinityTeamCaptainFromComment: issue already assigned") 175 | } 176 | 177 | if context.GitHubAuthedAs(*event.Sender.Login) { 178 | return fmt.Errorf("bozo. you can't reply to your own comment!") 179 | } 180 | 181 | context.IncrStat("affinity.issue_comment", nil) 182 | 183 | return assignTeamCaptains(context, *h, *event.Comment.Body, 1) 184 | } 185 | -------------------------------------------------------------------------------- /affinity/repo.go: -------------------------------------------------------------------------------- 1 | package affinity 2 | 3 | type Repo struct { 4 | Owner, Name string 5 | } 6 | -------------------------------------------------------------------------------- /affinity/team.go: -------------------------------------------------------------------------------- 1 | package affinity 2 | 3 | import ( 4 | "fmt" 5 | "math/rand" 6 | "time" 7 | 8 | "github.com/google/go-github/github" 9 | "github.com/parkr/auto-reply/ctx" 10 | ) 11 | 12 | func NewTeam(context *ctx.Context, teamId int64) (Team, error) { 13 | team := Team{ID: teamId} 14 | if err := team.fetchMetadata(context); err != nil { 15 | return Team{}, err 16 | } 17 | if err := team.fetchCaptains(context); err != nil { 18 | return Team{}, err 19 | } 20 | 21 | return team, nil 22 | } 23 | 24 | type Team struct { 25 | // The team ID. 26 | ID int64 27 | 28 | // The org the team belongs to 29 | Org string 30 | 31 | // The name of the team. 32 | Name string 33 | 34 | // The mention this should match, e.g. "@jekyll/documentation" 35 | Mention string 36 | 37 | // The description of the repo. 38 | Description string 39 | 40 | // Team captains, requires at least the Login field 41 | Captains []*github.User 42 | } 43 | 44 | func (t Team) String() string { 45 | return fmt.Sprintf("Team{ID=%d Org=%s Name=%s Mention=%s Description=%s Captains=%q", 46 | t.ID, 47 | t.Org, 48 | t.Name, 49 | t.Mention, 50 | t.Description, 51 | usersByLogin(t.Captains), 52 | ) 53 | } 54 | 55 | func (t Team) RandomCaptainLogins(num int) []string { 56 | rand.Seed(time.Now().UnixNano()) 57 | 58 | selectionmap := map[string]bool{} 59 | 60 | // Just return all of them. 61 | if len(t.Captains) <= num { 62 | return usersByLogin(t.Captains) 63 | } 64 | 65 | // Find a random selection. 66 | for { 67 | selection := t.Captains[rand.Intn(len(t.Captains))] 68 | selectionmap[selection.GetLogin()] = true 69 | 70 | if len(selectionmap) == num { 71 | break 72 | } 73 | } 74 | 75 | selections := []string{} 76 | for login := range selectionmap { 77 | selections = append(selections, login) 78 | } 79 | return selections 80 | } 81 | 82 | func (t Team) RandomCaptainLoginsExcluding(excludedLogin string, count int) []string { 83 | var selections []string 84 | 85 | // If the pool of captains isn't big enough to hit count without the excluded login, 86 | // then just return all the other captains. 87 | if len(t.Captains)-1 <= count { 88 | for _, user := range t.Captains { 89 | if user.GetLogin() != excludedLogin { 90 | selections = append(selections, user.GetLogin()) 91 | } 92 | } 93 | return selections 94 | } 95 | 96 | // We can only ever exclude 1 login, so just get count + 1 random logins 97 | // and pick them one by one until we get our desired count. 98 | for _, login := range t.RandomCaptainLogins(count + 1) { 99 | if login != excludedLogin { 100 | selections = append(selections, login) 101 | } 102 | if len(selections) == count { 103 | break 104 | } 105 | } 106 | 107 | return selections 108 | } 109 | 110 | func (t *Team) fetchCaptains(context *ctx.Context) error { 111 | users, _, err := context.GitHub.Teams.ListTeamMembers( 112 | context.Context(), 113 | t.ID, 114 | &github.TeamListTeamMembersOptions{ 115 | Role: "maintainer", 116 | ListOptions: github.ListOptions{Page: 0, PerPage: 100}, 117 | }, 118 | ) 119 | if err != nil { 120 | return err 121 | } 122 | 123 | for _, user := range users { 124 | if !context.GitHubAuthedAs(user.GetLogin()) { 125 | t.Captains = append(t.Captains, user) 126 | } 127 | } 128 | 129 | return nil 130 | } 131 | 132 | func (t *Team) fetchMetadata(context *ctx.Context) error { 133 | team, _, err := context.GitHub.Teams.GetTeam(context.Context(), t.ID) 134 | if err != nil { 135 | return err 136 | } 137 | 138 | t.Org = *team.Organization.Login 139 | t.Name = *team.Name 140 | t.Mention = fmt.Sprintf("@%s/%s", t.Org, *team.Slug) 141 | t.Description = *team.Description 142 | return nil 143 | } 144 | -------------------------------------------------------------------------------- /affinity/team_test.go: -------------------------------------------------------------------------------- 1 | package affinity 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/google/go-github/github" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestTeamRandomCaptainLogins(t *testing.T) { 11 | team := Team{Captains: []*github.User{ 12 | {Login: github.String("parkr")}, 13 | {Login: github.String("envygeeks")}, 14 | {Login: github.String("mattr-")}, 15 | }} 16 | selections := team.RandomCaptainLogins(1) 17 | assert.Len(t, selections, 1) 18 | assert.Contains(t, []string{"parkr", "envygeeks", "mattr-"}, selections[0]) 19 | 20 | selections = team.RandomCaptainLogins(2) 21 | assert.Len(t, selections, 2) 22 | assert.Contains(t, []string{"parkr", "envygeeks", "mattr-"}, selections[0]) 23 | assert.Contains(t, []string{"parkr", "envygeeks", "mattr-"}, selections[1]) 24 | 25 | selections = team.RandomCaptainLogins(3) 26 | assert.Len(t, selections, 3) 27 | assert.Contains(t, []string{"parkr", "envygeeks", "mattr-"}, selections[0]) 28 | assert.Contains(t, []string{"parkr", "envygeeks", "mattr-"}, selections[1]) 29 | assert.Contains(t, []string{"parkr", "envygeeks", "mattr-"}, selections[2]) 30 | 31 | selections = team.RandomCaptainLogins(4) 32 | assert.Len(t, selections, 3) 33 | assert.Contains(t, []string{"parkr", "envygeeks", "mattr-"}, selections[0]) 34 | assert.Contains(t, []string{"parkr", "envygeeks", "mattr-"}, selections[1]) 35 | assert.Contains(t, []string{"parkr", "envygeeks", "mattr-"}, selections[2]) 36 | } 37 | 38 | func TestTeamRandomCaptainLoginsExcluding(t *testing.T) { 39 | excluded := "parkr" 40 | team := Team{Captains: []*github.User{ 41 | {Login: github.String("parkr")}, 42 | {Login: github.String("envygeeks")}, 43 | {Login: github.String("mattr-")}, 44 | }} 45 | 46 | selections := team.RandomCaptainLoginsExcluding(excluded, 1) 47 | assert.Len(t, selections, 1) 48 | assert.Contains(t, []string{"envygeeks", "mattr-"}, selections[0]) 49 | 50 | selections = team.RandomCaptainLoginsExcluding(excluded, 2) 51 | assert.Len(t, selections, 2) 52 | assert.Contains(t, []string{"envygeeks", "mattr-"}, selections[0]) 53 | assert.Contains(t, []string{"envygeeks", "mattr-"}, selections[1]) 54 | 55 | selections = team.RandomCaptainLoginsExcluding(excluded, 3) 56 | assert.Len(t, selections, 2) 57 | assert.Contains(t, []string{"envygeeks", "mattr-"}, selections[0]) 58 | assert.Contains(t, []string{"envygeeks", "mattr-"}, selections[1]) 59 | } 60 | -------------------------------------------------------------------------------- /auth/auth.go: -------------------------------------------------------------------------------- 1 | // auth provides a means of determining use permissions on GitHub.com for repositories. 2 | package auth 3 | 4 | import ( 5 | "fmt" 6 | "log" 7 | 8 | "github.com/google/go-github/github" 9 | "github.com/parkr/auto-reply/ctx" 10 | ) 11 | 12 | var ( 13 | teamsCache = map[string][]*github.Team{} 14 | teamHasPushAccessCache = map[string]*github.Repository{} 15 | teamMembershipCache = map[string]bool{} 16 | orgOwnersCache = map[string][]*github.User{} 17 | ) 18 | 19 | type authenticator struct { 20 | context *ctx.Context 21 | } 22 | 23 | func CommenterHasPushAccess(context *ctx.Context, event github.IssueCommentEvent) bool { 24 | auth := authenticator{context: context} 25 | orgTeams := auth.teamsForOrg(*event.Repo.Owner.Login) 26 | for _, team := range orgTeams { 27 | if auth.isTeamMember(*team.ID, *event.Comment.User.Login) && 28 | auth.teamHasPushAccess(*team.ID, *event.Repo.Owner.Login, *event.Repo.Name) { 29 | return true 30 | } 31 | } 32 | return false 33 | } 34 | 35 | func UserIsOrgOwner(context *ctx.Context, org, login string) bool { 36 | auth := authenticator{context: context} 37 | for _, owner := range auth.ownersForOrg(org) { 38 | if *owner.Login == login { 39 | return true 40 | } 41 | } 42 | return false 43 | } 44 | 45 | func (auth authenticator) isTeamMember(teamId int64, login string) bool { 46 | cacheKey := auth.cacheKeyIsTeamMember(teamId, login) 47 | if _, ok := teamMembershipCache[cacheKey]; !ok { 48 | newOk, _, err := auth.context.GitHub.Teams.IsTeamMember( 49 | auth.context.Context(), teamId, login) 50 | if err != nil { 51 | log.Printf("ERROR performing IsTeamMember(%d, \"%s\"): %v", teamId, login, err) 52 | return false 53 | } 54 | teamMembershipCache[cacheKey] = newOk 55 | } 56 | return teamMembershipCache[cacheKey] 57 | } 58 | 59 | func (auth authenticator) teamHasPushAccess(teamId int64, owner, repo string) bool { 60 | cacheKey := auth.cacheKeyTeamHashPushAccess(teamId, owner, repo) 61 | if _, ok := teamHasPushAccessCache[cacheKey]; !ok { 62 | repository, _, err := auth.context.GitHub.Teams.IsTeamRepo( 63 | auth.context.Context(), teamId, owner, repo) 64 | if err != nil { 65 | log.Printf("ERROR performing IsTeamRepo(%d, \"%s\", \"%s\"): %v", teamId, owner, repo, err) 66 | return false 67 | } 68 | if repository == nil { 69 | return false 70 | } 71 | teamHasPushAccessCache[cacheKey] = repository 72 | } 73 | permissions := *teamHasPushAccessCache[cacheKey].Permissions 74 | return permissions["push"] || permissions["admin"] 75 | } 76 | 77 | func (auth authenticator) teamsForOrg(org string) []*github.Team { 78 | if _, ok := teamsCache[org]; !ok { 79 | teamz, _, err := auth.context.GitHub.Teams.ListTeams( 80 | auth.context.Context(), 81 | org, 82 | &github.ListOptions{Page: 0, PerPage: 100}, 83 | ) 84 | if err != nil { 85 | log.Printf("ERROR performing ListTeams(\"%s\"): %v", org, err) 86 | return nil 87 | } 88 | teamsCache[org] = teamz 89 | } 90 | return teamsCache[org] 91 | } 92 | 93 | func (auth authenticator) ownersForOrg(org string) []*github.User { 94 | if _, ok := orgOwnersCache[org]; !ok { 95 | owners, _, err := auth.context.GitHub.Organizations.ListMembers( 96 | auth.context.Context(), 97 | org, 98 | &github.ListMembersOptions{Role: "admin"}, // owners 99 | ) 100 | if err != nil { 101 | auth.context.Log("ERROR performing ListMembers(\"%s\"): %v", org, err) 102 | return nil 103 | } 104 | orgOwnersCache[org] = owners 105 | } 106 | return orgOwnersCache[org] 107 | } 108 | 109 | func (auth authenticator) cacheKeyIsTeamMember(teamId int64, login string) string { 110 | return fmt.Sprintf("%d_%s", teamId, login) 111 | } 112 | 113 | func (auth authenticator) cacheKeyTeamHashPushAccess(teamId int64, owner, repo string) string { 114 | return fmt.Sprintf("%d_%s_%s", teamId, owner, repo) 115 | } 116 | -------------------------------------------------------------------------------- /autopull/autopull.go: -------------------------------------------------------------------------------- 1 | // autopull provides a webhook which will automatically create pull requests for a push to a special branch name prefix. 2 | package autopull 3 | 4 | import ( 5 | "fmt" 6 | "log" 7 | "strings" 8 | 9 | "github.com/google/go-github/github" 10 | "github.com/parkr/auto-reply/ctx" 11 | ) 12 | 13 | type Handler struct { 14 | repos []string 15 | acceptAllRepos bool 16 | } 17 | 18 | func (h *Handler) handlesRepo(repo string) bool { 19 | for _, handled := range h.repos { 20 | if handled == repo { 21 | return true 22 | } 23 | } 24 | return false 25 | } 26 | 27 | func (h *Handler) AddRepo(owner, name string) { 28 | h.repos = append(h.repos, owner+"/"+name) 29 | } 30 | 31 | func (h *Handler) AcceptAllRepos(newValue bool) { 32 | h.acceptAllRepos = newValue 33 | } 34 | 35 | func (h *Handler) CreatePullRequestFromPush(context *ctx.Context, event interface{}) error { 36 | push, ok := event.(*github.PushEvent) 37 | if !ok { 38 | return context.NewError("AutoPull: not an push event") 39 | } 40 | 41 | if strings.HasPrefix(*push.Ref, "refs/heads/pull/") && (h.acceptAllRepos || h.handlesRepo(*push.Repo.FullName)) { 42 | pr := newPRForPush(push) 43 | if pr == nil { 44 | return context.NewError("AutoPull: no commits for %s on %s/%s", *push.Ref, *push.Repo.Owner.Name, *push.Repo.Name) 45 | } 46 | 47 | pull, _, err := context.GitHub.PullRequests.Create(context.Context(), *push.Repo.Owner.Name, *push.Repo.Name, pr) 48 | if err != nil { 49 | return context.NewError( 50 | "AutoPull: error creating pull request for %s on %s/%s: %v", 51 | *push.Ref, *push.Repo.Owner.Name, *push.Repo.Name, err, 52 | ) 53 | } 54 | log.Printf("created pull request: %s#%d", *push.Repo.FullName, *pull.Number) 55 | } 56 | 57 | return nil 58 | } 59 | 60 | func shortMessage(message string) string { 61 | return strings.SplitN(message, "\n", 1)[0] 62 | } 63 | 64 | // branchFromRef takes "refs/heads/pull/my-pull" and returns "pull/my-pull" 65 | func branchFromRef(ref string) string { 66 | return strings.Replace(ref, "refs/heads/", "", 1) 67 | } 68 | 69 | func prBodyForPush(push *github.PushEvent) string { 70 | var mention string 71 | if author := push.Commits[0].Author; author != nil { 72 | if author.Login != nil { 73 | mention = *author.Login 74 | } else { 75 | mention = *author.Name 76 | } 77 | } else { 78 | mention = "unknown" 79 | } 80 | return fmt.Sprintf( 81 | "PR automatically created for @%s.\n\n```text\n%s\n```", 82 | mention, 83 | *push.Commits[0].Message, 84 | ) 85 | } 86 | 87 | func newPRForPush(push *github.PushEvent) *github.NewPullRequest { 88 | if push.Commits == nil || len(push.Commits) == 0 { 89 | return nil 90 | } 91 | return &github.NewPullRequest{ 92 | Title: github.String(shortMessage(*push.Commits[0].Message)), 93 | Head: github.String(branchFromRef(*push.Ref)), 94 | Base: github.String("master"), 95 | Body: github.String(prBodyForPush(push)), 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /chlog/chlog.go: -------------------------------------------------------------------------------- 1 | // chlog provides a means of acting on the repository changelog. 2 | package chlog 3 | -------------------------------------------------------------------------------- /chlog/close_milestone_on_release.go: -------------------------------------------------------------------------------- 1 | package chlog 2 | 3 | import ( 4 | "github.com/google/go-github/github" 5 | "github.com/parkr/auto-reply/ctx" 6 | ) 7 | 8 | func CloseMilestoneOnRelease(context *ctx.Context, payload interface{}) error { 9 | release, ok := payload.(*github.ReleaseEvent) 10 | if !ok { 11 | return context.NewError("chlog.CloseMilestoneOnRelease: not a release event") 12 | } 13 | 14 | if *release.Action != "published" { 15 | return context.NewError("chlog.CloseMilestoneOnRelease: not a published release") 16 | } 17 | 18 | if *release.Release.Prerelease || *release.Release.Draft { 19 | return context.NewError("chlog.CloseMilestoneOnRelease: a prerelease or draft release") 20 | } 21 | 22 | owner, repo := *release.Repo.Owner.Login, *release.Repo.Name 23 | 24 | milestones, _, err := context.GitHub.Issues.ListMilestones(context.Context(), owner, repo, &github.MilestoneListOptions{ 25 | State: "open", 26 | ListOptions: github.ListOptions{Page: 0, PerPage: 200}, 27 | }) 28 | if err != nil { 29 | return context.NewError("chlog.CloseMilestoneOnRelease: couldn't fetch milestones for %s/%s: %+v", owner, repo, err) 30 | } 31 | 32 | for _, milestone := range milestones { 33 | if *milestone.Title == *release.Release.TagName { 34 | context.Log("chlog.CloseMilestoneOnRelease: found milestone (%d)", *milestone.Number) 35 | 36 | _, _, err := context.GitHub.Issues.EditMilestone( 37 | context.Context(), owner, repo, *milestone.Number, &github.Milestone{State: github.String("closed")}) 38 | if err != nil { 39 | return context.NewError("chlog.CloseMilestoneOnRelease: couldn't close milestone for %s/%s: %+v", owner, repo, err) 40 | } 41 | } 42 | } 43 | 44 | context.Log("chlog.CloseMilestoneOnRelease: no milestone with title '%s' on %s/%s", *release.Release.TagName, owner, repo) 45 | 46 | return nil 47 | } 48 | -------------------------------------------------------------------------------- /chlog/create_release_on_tag.go: -------------------------------------------------------------------------------- 1 | package chlog 2 | 3 | import ( 4 | "regexp" 5 | "strings" 6 | 7 | "github.com/google/go-github/github" 8 | "github.com/parkr/auto-reply/ctx" 9 | ) 10 | 11 | var versionTagRegexp = regexp.MustCompile(`v(\d+\.\d+\.\d+)(\.pre\.(beta|rc)\d+)?`) 12 | 13 | func CreateReleaseOnTagHandler(context *ctx.Context, payload interface{}) error { 14 | create, ok := payload.(*github.CreateEvent) 15 | if !ok { 16 | return context.NewError("chlog.CreateReleaseOnTagHandler: not a create event") 17 | } 18 | 19 | if *create.RefType != "tag" { 20 | return context.NewError("chlog.CreateReleaseOnTagHandler: not a tag create event") 21 | } 22 | 23 | version := extractVersion(*create.Ref) 24 | if version == "" { 25 | return context.NewError("chlog.CreateReleaseOnTagHandler: not a version tag (%s)", *create.Ref) 26 | } 27 | 28 | isPreRelease := strings.Index(version, ".pre") >= 0 29 | desiredRef := version 30 | if isPreRelease { 31 | // Working with a pre-release. Use HEAD. 32 | desiredRef = "HEAD" 33 | } 34 | 35 | owner, name := *create.Repo.Owner.Login, *create.Repo.Name 36 | 37 | // Read History.markdown, add line to appropriate change section 38 | historyFileContents, _ := getHistoryContents(context, owner, name) 39 | changes, err := parseChangelog(historyFileContents) 40 | if err != nil { 41 | return context.NewError("chlog.CreateReleaseOnTagHandler: could not parse history file: %v", err) 42 | } 43 | 44 | versionLog := changes.GetVersion(desiredRef) 45 | if versionLog == nil { 46 | return context.NewError("chlog.CreateReleaseOnTagHandler: no '%s' version in history file", desiredRef) 47 | } 48 | 49 | releaseBodyForVersion := strings.Join(strings.SplitN(versionLog.String(), "\n\n", 2)[1:], "\n") 50 | 51 | _, _, err = context.GitHub.Repositories.CreateRelease( 52 | context.Context(), 53 | owner, name, 54 | &github.RepositoryRelease{ 55 | TagName: create.Ref, 56 | Name: create.Ref, 57 | Body: github.String(releaseBodyForVersion), 58 | Draft: github.Bool(false), 59 | Prerelease: github.Bool(isPreRelease), 60 | }) 61 | if err != nil { 62 | context.NewError("chlog.CreateReleaseOnTagHandler: error creating release: %v", err) 63 | } 64 | 65 | return nil 66 | } 67 | 68 | func extractVersion(tag string) string { 69 | if versionTagRegexp.MatchString(tag) { 70 | return strings.Replace(tag, "v", "", 1) 71 | } 72 | return "" 73 | } 74 | -------------------------------------------------------------------------------- /chlog/create_release_on_tag_test.go: -------------------------------------------------------------------------------- 1 | package chlog 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestVersionTagRegexpMatchString(t *testing.T) { 8 | cases := map[string]bool{ 9 | "lgtm": false, 10 | "v2.3.0": true, 11 | "3.2.0": false, 12 | "v13.24.52": true, 13 | "v3.2.0.pre.beta12": true, 14 | } 15 | for input, expected := range cases { 16 | if actual := versionTagRegexp.MatchString(input); actual != expected { 17 | t.Fatalf("versionTagRegexp expected '%v' but got '%v' for `%s`", expected, actual, input) 18 | } 19 | } 20 | } 21 | 22 | func TestExtractVersion(t *testing.T) { 23 | cases := map[string]string{ 24 | "lgtm": "", 25 | "v2.3.0": "2.3.0", 26 | "3.2.0": "", 27 | "v13.24.52": "13.24.52", 28 | "v3.2.0.pre.beta12": "3.2.0.pre.beta12", 29 | } 30 | for input, expected := range cases { 31 | if actual := extractVersion(input); actual != expected { 32 | t.Fatalf("extractVersion expected '%v' but got '%v' for `%s`", expected, actual, input) 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /chlog/merge_and_label.go: -------------------------------------------------------------------------------- 1 | package chlog 2 | 3 | import ( 4 | "encoding/base64" 5 | "errors" 6 | "fmt" 7 | "log" 8 | "os" 9 | "regexp" 10 | "strings" 11 | "sync" 12 | "text/template" 13 | 14 | "github.com/google/go-github/github" 15 | "github.com/parkr/auto-reply/auth" 16 | "github.com/parkr/auto-reply/ctx" 17 | "github.com/parkr/changelog" 18 | ) 19 | 20 | // changelogCategory is a changelog category, like "Site Enhancements" and such. 21 | type changelogCategory struct { 22 | Prefix, Slug, Section string 23 | Labels []string 24 | } 25 | 26 | var ( 27 | mergeCommentRegexp = regexp.MustCompile("@[a-zA-Z-_]+: (merge|:shipit:|:ship:)( \\+([a-zA-Z-_ ]+))?") 28 | mergeOptions = &github.PullRequestOptions{MergeMethod: "squash"} 29 | 30 | categories = []changelogCategory{ 31 | { 32 | Prefix: "major", 33 | Slug: "major-enhancements", 34 | Section: "Major Enhancements", 35 | Labels: []string{"feature"}, 36 | }, 37 | { 38 | Prefix: "minor", 39 | Slug: "minor-enhancements", 40 | Section: "Minor Enhancements", 41 | Labels: []string{"enhancement"}, 42 | }, 43 | { 44 | Prefix: "bug", 45 | Slug: "bug-fixes", 46 | Section: "Bug Fixes", 47 | Labels: []string{"bug", "fix"}, 48 | }, 49 | { 50 | Prefix: "fix", 51 | Slug: "fix", 52 | Section: "Bug Fixes", 53 | Labels: []string{"bug", "fix"}, 54 | }, 55 | { 56 | Prefix: "dev", 57 | Slug: "development-fixes", 58 | Section: "Development Fixes", 59 | Labels: []string{"internal", "fix"}, 60 | }, 61 | { 62 | Prefix: "doc", 63 | Slug: "documentation", 64 | Section: "Documentation", 65 | Labels: []string{"documentation"}, 66 | }, 67 | { 68 | Prefix: "port", 69 | Slug: "forward-ports", 70 | Section: "Forward Ports", 71 | Labels: []string{"forward-port"}, 72 | }, 73 | { 74 | Prefix: "site", 75 | Slug: "site-enhancements", 76 | Section: "Site Enhancements", 77 | Labels: []string{"documentation"}, 78 | }, 79 | } 80 | ) 81 | 82 | func MergeAndLabel(context *ctx.Context, payload interface{}) error { 83 | event, ok := payload.(*github.IssueCommentEvent) 84 | if !ok { 85 | return context.NewError("MergeAndLabel: not an issue comment event") 86 | } 87 | 88 | // Is this a pull request? 89 | if event.Issue == nil || event.Issue.PullRequestLinks == nil { 90 | return context.NewError("MergeAndLabel: not a pull request") 91 | } 92 | 93 | var changeSectionLabel string 94 | isReq, labelFromComment := parseMergeRequestComment(*event.Comment.Body) 95 | 96 | // Is It a merge request comment? 97 | if !isReq { 98 | return context.NewError("MergeAndLabel: not a merge request comment") 99 | } 100 | 101 | if os.Getenv("AUTO_REPLY_DEBUG") == "true" { 102 | log.Println("MergeAndLabel: received event:", event) 103 | } 104 | 105 | var wg sync.WaitGroup 106 | 107 | owner, repo, number := *event.Repo.Owner.Login, *event.Repo.Name, *event.Issue.Number 108 | ref := fmt.Sprintf("%s/%s#%d", owner, repo, number) 109 | 110 | // Does the user have merge/label abilities? 111 | if !auth.CommenterHasPushAccess(context, *event) { 112 | log.Printf("%s isn't authenticated to merge anything on %s", *event.Comment.User.Login, *event.Repo.FullName) 113 | return errors.New("commenter isn't allowed to merge") 114 | } 115 | 116 | // Should it be labeled? 117 | if labelFromComment != "" { 118 | changeSectionLabel = sectionForLabel(labelFromComment) 119 | } else { 120 | changeSectionLabel = "none" 121 | } 122 | fmt.Printf("changeSectionLabel = '%s'\n", changeSectionLabel) 123 | 124 | // Merge 125 | commitMsg := fmt.Sprintf("Merge pull request %v", number) 126 | _, _, mergeErr := context.GitHub.PullRequests.Merge(context.Context(), owner, repo, number, commitMsg, mergeOptions) 127 | if mergeErr != nil { 128 | return context.NewError("MergeAndLabel: error merging %s: %v", ref, mergeErr) 129 | } 130 | 131 | // Delete branch 132 | repoInfo, _, getRepoErr := context.GitHub.PullRequests.Get(context.Context(), owner, repo, number) 133 | if getRepoErr != nil { 134 | return context.NewError("MergeAndLabel: error getting PR info %s: %v", ref, getRepoErr) 135 | } 136 | 137 | if repoInfo == nil { 138 | return context.NewError("MergeAndLabel: tried to get PR, but couldn't. repoInfo was nil.") 139 | } 140 | 141 | // Delete branch 142 | if deletableRef(repoInfo, owner) { 143 | wg.Add(1) 144 | go func() { 145 | ref := fmt.Sprintf("heads/%s", *repoInfo.Head.Ref) 146 | _, deleteBranchErr := context.GitHub.Git.DeleteRef(context.Context(), owner, repo, ref) 147 | if deleteBranchErr != nil { 148 | fmt.Printf("MergeAndLabel: error deleting branch %v\n", mergeErr) 149 | } 150 | wg.Done() 151 | }() 152 | } 153 | 154 | wg.Add(1) 155 | go func() { 156 | err := addLabelsForSubsection(context, owner, repo, number, changeSectionLabel) 157 | if err != nil { 158 | fmt.Printf("MergeAndLabel: error applying labels: %v\n", err) 159 | } 160 | wg.Done() 161 | }() 162 | 163 | wg.Add(1) 164 | go func() { 165 | // Read History.markdown, add line to appropriate change section 166 | historyFileContents, historySHA := getHistoryContents(context, owner, repo) 167 | 168 | // Add merge reference to history 169 | newHistoryFileContents := addMergeReference(historyFileContents, changeSectionLabel, *repoInfo.Title, number) 170 | 171 | // Commit change to History.markdown 172 | commitErr := commitHistoryFile(context, historySHA, owner, repo, number, newHistoryFileContents) 173 | if commitErr != nil { 174 | fmt.Printf("comments: error committing updated history %v\n", mergeErr) 175 | } 176 | wg.Done() 177 | }() 178 | 179 | wg.Wait() 180 | 181 | return nil 182 | } 183 | 184 | func parseMergeRequestComment(commentBody string) (bool, string) { 185 | matches := mergeCommentRegexp.FindAllStringSubmatch(commentBody, -1) 186 | if matches == nil || matches[0] == nil { 187 | return false, "" 188 | } 189 | 190 | var label string 191 | if len(matches[0]) >= 4 { 192 | if labelFromComment := matches[0][3]; labelFromComment != "" { 193 | label = downcaseAndHyphenize(labelFromComment) 194 | } 195 | } 196 | 197 | return true, normalizeLabel(label) 198 | } 199 | 200 | func downcaseAndHyphenize(label string) string { 201 | return strings.Replace(strings.ToLower(label), " ", "-", -1) 202 | } 203 | 204 | func normalizeLabel(label string) string { 205 | for _, category := range categories { 206 | if strings.HasPrefix(label, category.Prefix) { 207 | return category.Slug 208 | } 209 | } 210 | 211 | return label 212 | } 213 | 214 | func sectionForLabel(slug string) string { 215 | for _, category := range categories { 216 | if slug == category.Slug { 217 | return category.Section 218 | } 219 | } 220 | 221 | return slug 222 | } 223 | 224 | func labelsForSubsection(changeSectionLabel string) []string { 225 | for _, category := range categories { 226 | if changeSectionLabel == category.Section { 227 | return category.Labels 228 | } 229 | } 230 | 231 | return []string{} 232 | } 233 | 234 | func selectSectionLabel(labels []github.Label) string { 235 | for _, label := range labels { 236 | if sectionForLabel(*label.Name) != *label.Name { 237 | return *label.Name 238 | } 239 | } 240 | return "" 241 | } 242 | 243 | func containsChangeLabel(commentBody string) bool { 244 | _, labelFromComment := parseMergeRequestComment(commentBody) 245 | return labelFromComment != "" 246 | } 247 | 248 | func addLabelsForSubsection(context *ctx.Context, owner, repo string, number int, changeSectionLabel string) error { 249 | labels := labelsForSubsection(changeSectionLabel) 250 | 251 | if len(labels) < 1 { 252 | return fmt.Errorf("no labels for changeSectionLabel='%s'", changeSectionLabel) 253 | } 254 | 255 | _, _, err := context.GitHub.Issues.AddLabelsToIssue(context.Context(), owner, repo, number, labels) 256 | return err 257 | } 258 | 259 | func getHistoryContents(context *ctx.Context, owner, repo string) (content, sha string) { 260 | contents, _, _, err := context.GitHub.Repositories.GetContents( 261 | context.Context(), 262 | owner, 263 | repo, 264 | "History.markdown", 265 | &github.RepositoryContentGetOptions{Ref: "heads/master"}, 266 | ) 267 | if err != nil { 268 | fmt.Printf("comments: error getting History.markdown %v\n", err) 269 | return "", "" 270 | } 271 | return base64Decode(*contents.Content), *contents.SHA 272 | } 273 | 274 | func base64Decode(encoded string) string { 275 | decoded, err := base64.StdEncoding.DecodeString(encoded) 276 | if err != nil { 277 | fmt.Printf("comments: error decoding string: %v\n", err) 278 | return "" 279 | } 280 | return string(decoded) 281 | } 282 | 283 | func parseChangelog(historyFileContents string) (*changelog.Changelog, error) { 284 | changes, err := changelog.NewChangelogFromReader(strings.NewReader(historyFileContents)) 285 | if historyFileContents == "" { 286 | err = nil 287 | changes = changelog.NewChangelog() 288 | } 289 | return changes, err 290 | } 291 | 292 | func addMergeReference(historyFileContents, changeSectionLabel, prTitle string, number int) string { 293 | changes, err := parseChangelog(historyFileContents) 294 | if err != nil { 295 | fmt.Printf("comments: error %v\n", err) 296 | return historyFileContents 297 | } 298 | 299 | changeLine := &changelog.ChangeLine{ 300 | Summary: template.HTMLEscapeString(prTitle), 301 | Reference: fmt.Sprintf("#%d", number), 302 | } 303 | 304 | // Put either directly in the version history or in a subsection. 305 | if changeSectionLabel == "none" { 306 | changes.AddLineToVersion("HEAD", changeLine) 307 | } else { 308 | changes.AddLineToSubsection("HEAD", changeSectionLabel, changeLine) 309 | } 310 | 311 | return changes.String() 312 | } 313 | 314 | func deletableRef(pr *github.PullRequest, owner string) bool { 315 | return pr != nil && 316 | pr.Head != nil && 317 | pr.Head.Repo != nil && 318 | pr.Head.Repo.Owner != nil && 319 | pr.Head.Repo.Owner.Login != nil && 320 | *pr.Head.Repo.Owner.Login == owner && 321 | pr.Head.Ref != nil && 322 | *pr.Head.Ref != "master" && 323 | *pr.Head.Ref != "gh-pages" 324 | } 325 | 326 | func commitHistoryFile(context *ctx.Context, historySHA, owner, repo string, number int, newHistoryFileContents string) error { 327 | repositoryContentsOptions := &github.RepositoryContentFileOptions{ 328 | Message: github.String(fmt.Sprintf("Update history to reflect merge of #%d [ci skip]", number)), 329 | Content: []byte(newHistoryFileContents), 330 | SHA: github.String(historySHA), 331 | Committer: &github.CommitAuthor{ 332 | Name: github.String("jekyllbot"), 333 | Email: github.String("jekyllbot@jekyllrb.com"), 334 | }, 335 | } 336 | updateResponse, _, err := context.GitHub.Repositories.UpdateFile(context.Context(), owner, repo, "History.markdown", repositoryContentsOptions) 337 | if err != nil { 338 | fmt.Printf("comments: error committing History.markdown: %v\n", err) 339 | return err 340 | } 341 | fmt.Printf("comments: updateResponse: %s\n", updateResponse) 342 | return nil 343 | } 344 | -------------------------------------------------------------------------------- /chlog/merge_and_label_test.go: -------------------------------------------------------------------------------- 1 | package chlog 2 | 3 | import ( 4 | "io/ioutil" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestParseMergeRequestComment(t *testing.T) { 11 | comments := []struct { 12 | comment string 13 | isReq bool 14 | label string 15 | section string 16 | labels []string 17 | }{ 18 | {"it looked like you could merge it", false, "", "", []string{}}, 19 | {"@jekyllbot: merge", true, "", "", []string{}}, 20 | {"@jekyllbot: :shipit:", true, "", "", []string{}}, 21 | {"@jekyllbot: :ship:", true, "", "", []string{}}, 22 | {"@jekyllbot: merge +Site", true, "site-enhancements", "Site Enhancements", []string{"documentation"}}, 23 | {"@jekyllbot: merge +major", true, "major-enhancements", "Major Enhancements", []string{"feature"}}, 24 | {"@jekyllbot: merge +minor-enhancement", true, "minor-enhancements", "Minor Enhancements", []string{"enhancement"}}, 25 | {"@jekyllbot: merge +Bug Fix\n", true, "bug-fixes", "Bug Fixes", []string{"bug", "fix"}}, 26 | {"@jekyllbot: merge +port", true, "forward-ports", "Forward Ports", []string{"forward-port"}}, 27 | } 28 | for _, c := range comments { 29 | isReq, label := parseMergeRequestComment(c.comment) 30 | section := sectionForLabel(c.label) 31 | assert.Equal(t, c.isReq, isReq, "'%s' should have isReq=%v", c.comment, c.isReq) 32 | assert.Equal(t, c.label, label, "'%s' should have label=%v", c.comment, c.label) 33 | assert.Equal(t, c.section, section, "'%s' should have section=%v", c.comment, c.section) 34 | assert.Equal(t, c.labels, labelsForSubsection(section), "'%s' should have labels=%v", c.comment, c.labels) 35 | } 36 | } 37 | 38 | func TestBase64Decode(t *testing.T) { 39 | encoded, err := ioutil.ReadFile("history_contents.enc") 40 | assert.NoError(t, err) 41 | decoded := base64Decode(string(encoded)) 42 | assert.Contains(t, decoded, "### Minor Enhancements") 43 | } 44 | 45 | func TestAddMergeReference(t *testing.T) { 46 | historyFile := addMergeReference("", "Development Fixes", "Some great change", 1) 47 | assert.Equal(t, "## HEAD\n\n### Development Fixes\n\n * Some great change (#1)\n", historyFile) 48 | 49 | historyFile = addMergeReference( 50 | "## HEAD", 51 | "Development Fixes", "Another great change!!!!!!!", 1) 52 | assert.Equal(t, "## HEAD\n\n### Development Fixes\n\n * Another great change!!!!!!! (#1)\n", historyFile) 53 | 54 | historyFile = addMergeReference( 55 | "## HEAD\n\n### Development Fixes\n\n * Some great change (#1)\n", 56 | "Development Fixes", "Another great change!!!!!!!", 1) 57 | assert.Equal(t, "## HEAD\n\n### Development Fixes\n\n * Some great change (#1)\n * Another great change!!!!!!! (#1)\n", historyFile) 58 | 59 | historyFile = addMergeReference( 60 | "## HEAD\n\n### Development Fixes\n\n * Some great change (#1)\n", 61 | "Development Fixes", "Another great change for !!!!!!!", 1) 62 | assert.Equal(t, "## HEAD\n\n### Development Fixes\n\n * Some great change (#1)\n * Another great change for <science>!!!!!!! (#1)\n", historyFile) 63 | 64 | jekyllHistory, err := ioutil.ReadFile("History.markdown") 65 | assert.NoError(t, err) 66 | historyFile = addMergeReference(string(jekyllHistory), "Development Fixes", "A marvelous change.", 41526) 67 | assert.Contains(t, historyFile, "* A marvelous change. (#41526)\n\n### Site Enhancements") 68 | } 69 | -------------------------------------------------------------------------------- /cmd/check-for-outdated-dependencies/heroku.go: -------------------------------------------------------------------------------- 1 | // +build heroku 2 | 3 | package main 4 | 5 | import "log" 6 | import _ "github.com/heroku/x/hmetrics/onload" 7 | 8 | func init() { 9 | log.SetFlags(0) 10 | } 11 | -------------------------------------------------------------------------------- /cmd/check-for-outdated-dependencies/main.go: -------------------------------------------------------------------------------- 1 | // check-for-outdated-dependencies takes a repo 2 | package main 3 | 4 | import ( 5 | "flag" 6 | "fmt" 7 | "log" 8 | "strings" 9 | 10 | "github.com/parkr/auto-reply/ctx" 11 | "github.com/parkr/auto-reply/dependencies" 12 | "github.com/parkr/auto-reply/sentry" 13 | ) 14 | 15 | var defaultRepos = strings.Join([]string{ 16 | "jekyll/jekyll", 17 | "jekyll/jekyll-watch", 18 | }, ",") 19 | 20 | func process(reposString string, perform bool) error { 21 | context := ctx.NewDefaultContext() 22 | 23 | for _, repo := range strings.Split(reposString, ",") { 24 | pieces := strings.SplitN(repo, "/", 2) 25 | repoOwner, repoName := pieces[0], pieces[1] 26 | checker := dependencies.NewRubyDependencyChecker(repoOwner, repoName) 27 | outdated := checker.AllOutdatedDependencies(context) 28 | for _, dependency := range outdated { 29 | log.Printf( 30 | "%s/%s: %s is outdated (constraint: %s, but latest version is %s)", 31 | repoOwner, repoName, dependency.GetName(), dependency.GetConstraint(), dependency.GetLatestVersion(context), 32 | ) 33 | 34 | // Do not open issues if dry-run. 35 | if !perform { 36 | continue 37 | } 38 | 39 | preExistingIssue := dependencies.GitHubUpdateIssueForDependency(context, repoOwner, repoName, dependency) 40 | 41 | if preExistingIssue == nil { 42 | issue, err := dependencies.FileGitHubIssueForDependency(context, repoOwner, repoName, dependency) 43 | if err != nil { 44 | log.Printf("%s/%s: error creating issue for %s: %v", repoOwner, repoName, dependency.GetName(), err) 45 | return err 46 | } else { 47 | log.Printf("%s/%s: issue for %s filed: %s", repoOwner, repoName, dependency.GetName(), *issue.HTMLURL) 48 | } 49 | } else { 50 | log.Printf("%s/%s: issue for %s already open: %s", 51 | repoOwner, repoName, dependency.GetName(), *preExistingIssue.HTMLURL) 52 | } 53 | } 54 | } 55 | 56 | return nil 57 | } 58 | 59 | func main() { 60 | var depType string 61 | flag.StringVar(&depType, "type", "ruby", "The type of dependency we're checking (options: ruby)") 62 | var reposString string 63 | flag.StringVar(&reposString, "repos", defaultRepos, "Comma-separated list of repos to check, e.g. jekyll/jekyll,jekyll/jekyll-import") 64 | var perform bool 65 | flag.BoolVar(&perform, "f", false, "Whether to open issues (default: false, which is a dry-run)") 66 | flag.Parse() 67 | 68 | log.SetPrefix("check-for-outdated-dependencies: ") 69 | 70 | sentryClient, err := sentry.NewClient(map[string]string{ 71 | "app": "check-for-outdated-dependencies", 72 | "depType": depType, 73 | "reposString": reposString, 74 | "perform": fmt.Sprintf("%t", perform), 75 | }) 76 | if err != nil { 77 | panic(err) 78 | } 79 | sentryClient.Recover(func() error { 80 | return process(reposString, perform) 81 | }) 82 | } 83 | -------------------------------------------------------------------------------- /cmd/freeze-ancient-issues/heroku.go: -------------------------------------------------------------------------------- 1 | // +build heroku 2 | 3 | package main 4 | 5 | import "log" 6 | import _ "github.com/heroku/x/hmetrics/onload" 7 | 8 | func init() { 9 | log.SetFlags(0) 10 | } 11 | -------------------------------------------------------------------------------- /cmd/freeze-ancient-issues/main.go: -------------------------------------------------------------------------------- 1 | // A command-line utility to lock old issues. 2 | package main 3 | 4 | import ( 5 | "errors" 6 | "flag" 7 | "fmt" 8 | "log" 9 | "strconv" 10 | "strings" 11 | "sync" 12 | "time" 13 | 14 | "github.com/google/go-github/github" 15 | "github.com/parkr/auto-reply/ctx" 16 | "github.com/parkr/auto-reply/freeze" 17 | "github.com/parkr/auto-reply/sentry" 18 | ) 19 | 20 | type repository struct { 21 | Owner, Name string 22 | } 23 | 24 | var ( 25 | defaultRepos = []repository{ 26 | {"jekyll", "jekyll"}, 27 | {"jekyll", "jekyll-import"}, 28 | {"jekyll", "github-metadata"}, 29 | {"jekyll", "jekyll-redirect-from"}, 30 | {"jekyll", "jekyll-feed"}, 31 | {"jekyll", "jekyll-compose"}, 32 | {"jekyll", "jekyll-watch"}, 33 | {"jekyll", "jekyll-seo-tag"}, 34 | {"jekyll", "jekyll-sitemap"}, 35 | {"jekyll", "jekyll-sass-converter"}, 36 | {"jekyll", "jemoji"}, 37 | {"jekyll", "jekyll-gist"}, 38 | {"jekyll", "jekyll-coffeescript"}, 39 | {"jekyll", "directory"}, 40 | } 41 | 42 | sleepBetweenFreezes = 150 * time.Millisecond 43 | ) 44 | 45 | func main() { 46 | var actuallyDoIt bool 47 | flag.BoolVar(&actuallyDoIt, "f", false, "Whether to actually mark the issues or close them.") 48 | var inputRepos string 49 | flag.StringVar(&inputRepos, "repos", "", "Specify a list of comma-separated repo name/owner pairs, e.g. 'jekyll/jekyll-import'.") 50 | flag.Parse() 51 | 52 | var repos []repository 53 | if inputRepos == "" { 54 | repos = defaultRepos 55 | } 56 | 57 | log.SetPrefix("freeze-ancient-issues: ") 58 | 59 | sentryClient, err := sentry.NewClient(map[string]string{ 60 | "app": "freeze-ancient-issues", 61 | "inputRepos": inputRepos, 62 | "actuallyDoIt": fmt.Sprintf("%t", actuallyDoIt), 63 | }) 64 | if err != nil { 65 | panic(err) 66 | } 67 | sentryClient.Recover(func() error { 68 | context := ctx.NewDefaultContext() 69 | if context.GitHub == nil { 70 | return errors.New("cannot proceed without github client") 71 | } 72 | 73 | // Support running on just a list of issues. Either a URL or a `owner/name#number` syntax. 74 | if flag.NArg() > 0 { 75 | return processSingleIssues(context, actuallyDoIt, flag.Args()...) 76 | } 77 | 78 | var wg sync.WaitGroup 79 | for _, repo := range repos { 80 | wg.Add(1) 81 | go func(context *ctx.Context, repo repository, actuallyDoIt bool) { 82 | defer wg.Done() 83 | if err := processRepo(context, repo.Owner, repo.Name, actuallyDoIt); err != nil { 84 | log.Printf("%s/%s: error: %#v", repo.Owner, repo.Name, err) 85 | sentryClient.GetSentry().CaptureErrorAndWait(err, map[string]string{ 86 | "method": "processRepo", 87 | "repo": repo.Owner + "/" + repo.Name, 88 | }) 89 | } 90 | }(context, repo, actuallyDoIt) 91 | } 92 | 93 | // TODO: use errgroup and return the error from wg.Wait() 94 | wg.Wait() 95 | return nil 96 | }) 97 | } 98 | 99 | func extractIssueInfo(issueName string) (owner, repo string, number int) { 100 | issueName = strings.TrimPrefix(issueName, "https://github.com/") 101 | 102 | var err error 103 | pieces := strings.Split(issueName, "/") 104 | 105 | // Ex: `owner/repo#number` 106 | if len(pieces) == 2 { 107 | owner = pieces[0] 108 | morePieces := strings.Split(pieces[1], "#") 109 | if len(morePieces) == 2 { 110 | repo = morePieces[0] 111 | number, err = strconv.Atoi(morePieces[1]) 112 | if err != nil { 113 | log.Printf("huh? %#v for %s", err, morePieces[1]) 114 | } 115 | } 116 | return 117 | } 118 | 119 | // Ex: `owner/repo/issues/number` 120 | if len(pieces) == 4 { 121 | owner = pieces[0] 122 | repo = pieces[1] 123 | number, err = strconv.Atoi(pieces[3]) 124 | if err != nil { 125 | log.Printf("huh? %#v for %s", err, pieces[3]) 126 | } 127 | return 128 | } 129 | 130 | return "", "", 0 131 | } 132 | 133 | func processSingleIssues(context *ctx.Context, actuallyDoIt bool, issueNames ...string) error { 134 | issues := []github.Issue{} 135 | for _, issueName := range issueNames { 136 | owner, repo, number := extractIssueInfo(issueName) 137 | if owner == "" || repo == "" || number <= 0 { 138 | return fmt.Errorf("couldn't extract issue info from '%s': owner=%s repo=%s number=%d", 139 | issueName, owner, repo, number) 140 | } 141 | 142 | issues = append(issues, github.Issue{ 143 | Number: github.Int(number), 144 | Repository: &github.Repository{ 145 | Owner: &github.User{Login: github.String(owner)}, 146 | Name: github.String(repo), 147 | }, 148 | }) 149 | } 150 | return processIssues(context, actuallyDoIt, issues) 151 | } 152 | 153 | func processRepo(context *ctx.Context, owner, repo string, actuallyDoIt bool) error { 154 | start := time.Now() 155 | 156 | issues, err := freeze.AllTooOldIssues(context, owner, repo) 157 | if err != nil { 158 | return err 159 | } 160 | 161 | log.Printf("%s/%s: freezing %d closed issues before %v", owner, repo, len(issues), freeze.TooOld) 162 | err = processIssues(context, actuallyDoIt, issues) 163 | log.Printf("%s/%s: finished in %s", owner, repo, time.Since(start)) 164 | 165 | return err 166 | } 167 | 168 | func processIssues(context *ctx.Context, actuallyDoIt bool, issues []github.Issue) error { 169 | for _, issue := range issues { 170 | owner, repo := *issue.Repository.Owner.Login, *issue.Repository.Name 171 | if actuallyDoIt { 172 | log.Printf("%s/%s: freezing %s", owner, repo, *issue.HTMLURL) 173 | if err := freeze.Freeze(context, owner, repo, *issue.Number); err != nil { 174 | return err 175 | } 176 | time.Sleep(sleepBetweenFreezes) 177 | } else { 178 | log.Printf("%s/%s: would have frozen %s", owner, repo, *issue.HTMLURL) 179 | time.Sleep(sleepBetweenFreezes) 180 | } 181 | } 182 | return nil 183 | } 184 | -------------------------------------------------------------------------------- /cmd/jekyllbot/heroku.go: -------------------------------------------------------------------------------- 1 | // +build heroku 2 | 3 | package main 4 | 5 | import "log" 6 | import _ "github.com/heroku/x/hmetrics/onload" 7 | 8 | func init() { 9 | log.SetFlags(0) 10 | } 11 | -------------------------------------------------------------------------------- /cmd/jekyllbot/jekyllbot.go: -------------------------------------------------------------------------------- 1 | // jekyllbot is the server which controls @jekyllbot on GitHub. 2 | package main 3 | 4 | import ( 5 | _ "expvar" 6 | "flag" 7 | "log" 8 | "net/http" 9 | 10 | "github.com/parkr/auto-reply/ctx" 11 | "github.com/parkr/auto-reply/jekyll" 12 | "github.com/parkr/auto-reply/sentry" 13 | ) 14 | 15 | var context *ctx.Context 16 | 17 | func main() { 18 | var port string 19 | flag.StringVar(&port, "port", "8080", "The port to serve to") 20 | flag.Parse() 21 | context = ctx.NewDefaultContext() 22 | 23 | http.HandleFunc("/_ping", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 24 | w.Header().Set("Content-Type", "text/plain") 25 | w.Write([]byte("ok\n")) 26 | })) 27 | 28 | jekyllOrgHandler := jekyll.NewJekyllOrgHandler(context) 29 | http.Handle("/_github/jekyll", sentry.NewHTTPHandler(jekyllOrgHandler, map[string]string{ 30 | "app": "jekyllbot", 31 | })) 32 | 33 | log.Printf("Listening on :%s", port) 34 | log.Fatal(http.ListenAndServe(":"+port, nil)) 35 | } 36 | -------------------------------------------------------------------------------- /cmd/mark-and-sweep-stale-issues/heroku.go: -------------------------------------------------------------------------------- 1 | // +build heroku 2 | 3 | package main 4 | 5 | import "log" 6 | import _ "github.com/heroku/x/hmetrics/onload" 7 | 8 | func init() { 9 | log.SetFlags(log.Lshortfile) 10 | } 11 | -------------------------------------------------------------------------------- /cmd/mark-and-sweep-stale-issues/mark_and_sweep_stale_issues.go: -------------------------------------------------------------------------------- 1 | // A command-line utility to mark and sweep Jekyll issues for staleness. 2 | package main 3 | 4 | import ( 5 | "context" 6 | "flag" 7 | "fmt" 8 | "log" 9 | "strings" 10 | "time" 11 | 12 | "github.com/google/go-github/github" 13 | "github.com/parkr/auto-reply/ctx" 14 | "github.com/parkr/auto-reply/sentry" 15 | "github.com/parkr/auto-reply/stale" 16 | "golang.org/x/sync/errgroup" 17 | ) 18 | 19 | type repo struct { 20 | Owner, Name string 21 | } 22 | 23 | var ( 24 | // Labels which mean the issue is already stale. 25 | staleLabels = []string{ 26 | "pending-feedback", 27 | } 28 | 29 | // Labels which can be used to disable the staleable functionality for an issue. 30 | nonStaleableLabels = []string{ 31 | "has-pull-request", 32 | "pinned", 33 | "release", 34 | "security", 35 | } 36 | 37 | // All the repos to apply apply these to. 38 | defaultRepos = []repo{ 39 | {"jekyll", "jekyll"}, 40 | {"jekyll", "jekyll-admin"}, 41 | {"jekyll", "jekyll-import"}, 42 | {"jekyll", "github-metadata"}, 43 | {"jekyll", "jekyll-redirect-from"}, 44 | {"jekyll", "jekyll-feed"}, 45 | {"jekyll", "jekyll-compose"}, 46 | {"jekyll", "jekyll-commonmark"}, 47 | {"jekyll", "jekyll-docs"}, 48 | {"jekyll", "jekyll-watch"}, 49 | {"jekyll", "jekyll-seo-tag"}, 50 | {"jekyll", "jekyll-sitemap"}, 51 | {"jekyll", "jekyll-sass-converter"}, 52 | {"jekyll", "jemoji"}, 53 | {"jekyll", "jekyll-gist"}, 54 | {"jekyll", "jekyll-coffeescript"}, 55 | {"jekyll", "minima"}, 56 | {"jekyll", "directory"}, 57 | } 58 | 59 | twoMonthsAgo = time.Now().AddDate(0, -2, 0) 60 | 61 | staleIssuesListOptions = &github.IssueListByRepoOptions{ 62 | State: "open", 63 | Sort: "updated", 64 | Direction: "asc", 65 | ListOptions: github.ListOptions{PerPage: 200}, 66 | } 67 | 68 | staleJekyllIssueComment = &github.IssueComment{ 69 | Body: github.String(` 70 | This issue has been automatically marked as stale because it has not been commented on for at least two months. 71 | 72 | The resources of the Jekyll team are limited, and so we are asking for your help. 73 | 74 | If this is a **bug** and you can still reproduce this error on the latest 3.x-stable or master branch, please reply with all of the information you have about it in order to keep the issue open. 75 | 76 | If this is a **feature request**, please consider building it first as a plugin. Jekyll 3 introduced [hooks](http://jekyllrb.com/docs/plugins/#hooks) which provide convenient access points throughout the Jekyll build pipeline whereby most needs can be fulfilled. If this is something that cannot be built as a plugin, then please provide more information about why in order to keep this issue open. 77 | 78 | This issue will automatically be closed in two months if no further activity occurs. Thank you for all your contributions. 79 | `), 80 | } 81 | 82 | staleNonJekyllIssueComment = &github.IssueComment{ 83 | Body: github.String(` 84 | This issue has been automatically marked as stale because it has not been commented on for at least two months. 85 | 86 | The resources of the Jekyll team are limited, and so we are asking for your help. 87 | 88 | If this is a **bug** and you can still reproduce this error on the master branch, please reply with all of the information you have about it in order to keep the issue open. 89 | 90 | If this is a feature request, please consider whether it can be accomplished in another way. If it cannot, please elaborate on why it is core to this project and why you feel more than 80% of users would find this beneficial. 91 | 92 | This issue will automatically be closed in two months if no further activity occurs. Thank you for all your contributions. 93 | `), 94 | } 95 | ) 96 | 97 | func main() { 98 | var actuallyDoIt bool 99 | flag.BoolVar(&actuallyDoIt, "f", false, "Whether to actually mark the issues or close them.") 100 | var inputRepos string 101 | flag.StringVar(&inputRepos, "repos", "", "Specify a list of comma-separated repo name/owner pairs, e.g. 'jekyll/jekyll-import'.") 102 | flag.Parse() 103 | 104 | if ctx.NewDefaultContext().GitHub == nil { 105 | log.Fatalln("cannot proceed without github client") 106 | } 107 | 108 | var repos []repo 109 | if inputRepos != "" { 110 | for _, nwo := range strings.Split(inputRepos, ",") { 111 | pieces := strings.Split(nwo, "/") 112 | repos = append(repos, repo{Owner: pieces[0], Name: pieces[1]}) 113 | } 114 | } else { 115 | repos = defaultRepos 116 | } 117 | 118 | log.SetPrefix("mark-and-sweep-stale-issues: ") 119 | 120 | sentryClient, err := sentry.NewClient(map[string]string{ 121 | "app": "mark-and-sweep-stale-issues", 122 | "inputRepos": inputRepos, 123 | "actuallyDoIt": fmt.Sprintf("%t", actuallyDoIt), 124 | }) 125 | if err != nil { 126 | panic(err) 127 | } 128 | 129 | sentryClient.Recover(func() error { 130 | wg, _ := errgroup.WithContext(context.Background()) 131 | for _, repo := range repos { 132 | repo := repo 133 | wg.Go(func() error { 134 | return stale.MarkAndCloseForRepo( 135 | ctx.WithRepo(repo.Owner, repo.Name), 136 | stale.Configuration{ 137 | Perform: actuallyDoIt, 138 | StaleLabels: staleLabels, 139 | ExemptLabels: nonStaleableLabels, 140 | DormantDuration: time.Since(twoMonthsAgo), 141 | NotificationComment: staleIssueComment(repo.Owner, repo.Name), 142 | }, 143 | ) 144 | }) 145 | } 146 | return wg.Wait() 147 | }) 148 | } 149 | 150 | func staleIssueComment(repoOwner, repoName string) *github.IssueComment { 151 | if repoName == "jekyll" { 152 | return staleJekyllIssueComment 153 | } else { 154 | return staleNonJekyllIssueComment 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /cmd/nudge-maintainers-to-release/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "flag" 7 | "fmt" 8 | "html/template" 9 | "log" 10 | "strings" 11 | "time" 12 | 13 | "github.com/google/go-github/github" 14 | "github.com/parkr/auto-reply/ctx" 15 | "github.com/parkr/auto-reply/jekyll" 16 | "github.com/parkr/auto-reply/releases" 17 | "github.com/parkr/auto-reply/search" 18 | "github.com/parkr/auto-reply/sentry" 19 | "github.com/parkr/githubapi/githubsearch" 20 | "golang.org/x/sync/errgroup" 21 | ) 22 | 23 | var ( 24 | defaultRepos = jekyll.DefaultRepos 25 | 26 | threeMonthsAgoUnix = time.Now().AddDate(0, -3, 0).Unix() 27 | 28 | issueTitle = "Time for a new release" 29 | issueLabels = []string{"release"} 30 | 31 | issueBodyTemplate = template.Must(template.New("issueBodyTemplate").Parse(` 32 | Hello, maintainers! :wave: 33 | 34 | By my calculations, it's time for a new release of {{.Repo.Name}}. {{if gt .CommitsOnMasterSinceLatestRelease 100}}There have been {{.CommitsOnMasterSinceLatestRelease}} commits{{else}}It's been over 3 months{{end}} since the last release, {{.LatestRelease.TagName}}. 35 | 36 | What else is left to be done before a new release can be made? Please make sure to update History.markdown too if it's not already updated. 37 | 38 | Thanks! :revolving_hearts: :sparkles: 39 | `)) 40 | ) 41 | 42 | type templateInfo struct { 43 | Repo jekyll.Repository 44 | CommitsOnMasterSinceLatestRelease int 45 | LatestRelease *github.RepositoryRelease 46 | } 47 | 48 | func main() { 49 | var perform bool 50 | flag.BoolVar(&perform, "f", false, "Whether to actually file issues.") 51 | var inputRepos string 52 | flag.StringVar(&inputRepos, "repos", "", "Specify a list of comma-separated repo name/owner pairs, e.g. 'jekyll/jekyll-import'.") 53 | flag.Parse() 54 | 55 | // Get latest 10 releases. 56 | // Sort releases by semver version, taking highest one. 57 | // 58 | // Has there been 100 commits since this release? If so, make an issue. 59 | // Has at least 1 commit been made since this release & is this release at least 2 month old? If so, make an issue. 60 | 61 | var repos []jekyll.Repository 62 | if inputRepos == "" { 63 | repos = defaultRepos 64 | } 65 | 66 | log.SetPrefix("nudge-maintainers-to-release: ") 67 | 68 | sentryClient, err := sentry.NewClient(map[string]string{ 69 | "app": "nudge-maintainers-to-release", 70 | "inputRepos": inputRepos, 71 | "actuallyDoIt": fmt.Sprintf("%t", perform), 72 | }) 73 | if err != nil { 74 | panic(err) 75 | } 76 | sentryClient.Recover(func() error { 77 | context := ctx.NewDefaultContext() 78 | if context.GitHub == nil { 79 | return errors.New("cannot proceed without github client") 80 | } 81 | 82 | if inputRepos != "" { 83 | repos = []jekyll.Repository{} 84 | for _, inputRepo := range strings.Split(inputRepos, ",") { 85 | pieces := strings.Split(inputRepo, "/") 86 | if len(pieces) != 2 { 87 | return fmt.Errorf("input repo %q is improperly formed", inputRepo) 88 | } 89 | repos = append(repos, jekyll.NewRepository(pieces[0], pieces[1])) 90 | } 91 | } 92 | 93 | wg, _ := errgroup.WithContext(context.Context()) 94 | for _, repo := range repos { 95 | repo := repo 96 | wg.Go(func() error { 97 | latestRelease, err := releases.LatestRelease(context, repo) 98 | if err != nil { 99 | log.Printf("%s error fetching latest release: %+v", repo, err) 100 | return err 101 | } 102 | if latestRelease == nil { 103 | log.Printf("%s has no releases", repo) 104 | return nil 105 | } 106 | 107 | commitsSinceLatestRelease, err := releases.CommitsSinceRelease(context, repo, latestRelease) 108 | if err != nil { 109 | log.Printf("%s error fetching commits since latest release: %+v", repo, err) 110 | return err 111 | } 112 | 113 | if commitsSinceLatestRelease > 100 || (commitsSinceLatestRelease >= 3 && latestRelease.GetCreatedAt().Unix() <= threeMonthsAgoUnix) { 114 | if perform { 115 | err := fileIssue(context, templateInfo{ 116 | Repo: repo, 117 | LatestRelease: latestRelease, 118 | CommitsOnMasterSinceLatestRelease: commitsSinceLatestRelease, 119 | }) 120 | if err != nil { 121 | log.Printf("%s: nudged maintainers (release=%s commits=%d released_on=%s)", 122 | repo, 123 | latestRelease.GetTagName(), 124 | commitsSinceLatestRelease, 125 | latestRelease.GetCreatedAt(), 126 | ) 127 | } 128 | return err 129 | } else { 130 | log.Printf("%s is in need of a nudge (release=%s commits=%d released_on=%s)", 131 | repo, 132 | latestRelease.GetTagName(), 133 | commitsSinceLatestRelease, 134 | latestRelease.GetCreatedAt(), 135 | ) 136 | } 137 | } else { 138 | log.Printf("%s is NOT in need of a nudge: (release=%s commits=%d released_on=%s)", 139 | repo, 140 | latestRelease.GetTagName(), 141 | commitsSinceLatestRelease, 142 | latestRelease.GetCreatedAt(), 143 | ) 144 | } 145 | 146 | return nil 147 | }) 148 | } 149 | return wg.Wait() 150 | }) 151 | } 152 | 153 | func fileIssue(context *ctx.Context, issueInfo templateInfo) error { 154 | if issue := getReleaseNudgeIssue(context, issueInfo.Repo); issue != nil { 155 | return fmt.Errorf("%s: issue already exists: %s", issueInfo.Repo, issue.GetHTMLURL()) 156 | } 157 | 158 | var body bytes.Buffer 159 | if err := issueBodyTemplate.Execute(&body, issueInfo); err != nil { 160 | return fmt.Errorf("%s: error executing template: %+v", issueInfo.Repo, err) 161 | } 162 | 163 | issue, _, err := context.GitHub.Issues.Create( 164 | context.Context(), 165 | issueInfo.Repo.Owner(), issueInfo.Repo.Name(), 166 | &github.IssueRequest{ 167 | Title: &issueTitle, 168 | Labels: &issueLabels, 169 | Body: github.String(body.String()), 170 | }, 171 | ) 172 | if err != nil { 173 | return fmt.Errorf("%s: error filing issue: %+v", issueInfo.Repo, err) 174 | } 175 | 176 | log.Printf("%s filed %s", issueInfo.Repo, issue.GetHTMLURL()) 177 | return nil 178 | } 179 | 180 | func getReleaseNudgeIssue(context *ctx.Context, repo jekyll.Repository) *github.Issue { 181 | query := githubsearch.IssueSearchParameters{ 182 | Type: githubsearch.Issue, 183 | Scope: githubsearch.TitleScope, 184 | Author: context.CurrentlyAuthedGitHubUser().GetLogin(), 185 | State: githubsearch.Open, 186 | Repository: &githubsearch.RepositoryName{Owner: repo.Owner(), Name: repo.Name()}, 187 | Query: issueTitle, 188 | } 189 | issues, err := search.GitHubIssues(context, query) 190 | if err != nil { 191 | log.Printf("%s: error searching %s: %+v", repo, query, err) 192 | return nil 193 | } 194 | if len(issues) > 0 { 195 | return &(issues[0]) 196 | } 197 | return nil 198 | } 199 | -------------------------------------------------------------------------------- /cmd/unearth/heroku.go: -------------------------------------------------------------------------------- 1 | // +build heroku 2 | 3 | package main 4 | 5 | import "log" 6 | import _ "github.com/heroku/x/hmetrics/onload" 7 | 8 | func init() { 9 | log.SetFlags(0) 10 | } 11 | -------------------------------------------------------------------------------- /cmd/unearth/unearth.go: -------------------------------------------------------------------------------- 1 | // A command-line utility to run a search query and display results. 2 | package main 3 | 4 | import ( 5 | "flag" 6 | "fmt" 7 | "log" 8 | "strings" 9 | 10 | "github.com/google/go-github/github" 11 | "github.com/parkr/auto-reply/ctx" 12 | ) 13 | 14 | var ( 15 | context *ctx.Context 16 | 17 | defaultListOptions = &github.ListOptions{Page: 0, PerPage: 200} 18 | ) 19 | 20 | func haltIfError(err error) { 21 | if err != nil { 22 | log.Fatal(err) 23 | } 24 | } 25 | 26 | func repoNameFromURL(url string) string { 27 | return strings.Join( 28 | strings.SplitN( 29 | strings.Replace(url, "https://github.com/", "", 1), 30 | "/", 31 | -1)[1:2], 32 | "/", 33 | ) 34 | } 35 | 36 | func issuesForQuery(query string) { 37 | result, _, err := context.GitHub.Search.Issues( 38 | context.Context(), 39 | query, 40 | &github.SearchOptions{ 41 | Sort: "created", 42 | ListOptions: *defaultListOptions, 43 | }) 44 | haltIfError(err) 45 | fmt.Printf("Query '%s' found %d issues:\n", query, *result.Total) 46 | for _, issue := range result.Issues { 47 | fmt.Printf("%-20s %-4d %s | %s\n", 48 | repoNameFromURL(*issue.HTMLURL), 49 | *issue.Number, 50 | issue.CreatedAt.Format("2006-01-02"), 51 | *issue.Title, 52 | ) 53 | } 54 | } 55 | 56 | func main() { 57 | flag.Parse() 58 | context = ctx.NewDefaultContext() 59 | 60 | if flag.NArg() < 1 { 61 | // Default queries. 62 | issuesForQuery("state:open comments:0 user:jekyll") 63 | fmt.Print("\n\n") 64 | issuesForQuery("state:open comments:>10 user:jekyll") 65 | } else { 66 | // User-specified query. 67 | issuesForQuery(strings.Join(flag.Args(), " ")) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /cmd/unify-labels/heroku.go: -------------------------------------------------------------------------------- 1 | // +build heroku 2 | 3 | package main 4 | 5 | import "log" 6 | import _ "github.com/heroku/x/hmetrics/onload" 7 | 8 | func init() { 9 | log.SetFlags(0) 10 | } 11 | -------------------------------------------------------------------------------- /cmd/unify-labels/unify_labels.go: -------------------------------------------------------------------------------- 1 | // unify-labels is a CLI which will add, rename, or change the color of labels so they match the Jekyll org's requirements. 2 | package main 3 | 4 | import ( 5 | "flag" 6 | "fmt" 7 | "log" 8 | "strings" 9 | 10 | "github.com/google/go-github/github" 11 | "github.com/parkr/auto-reply/ctx" 12 | "github.com/parkr/auto-reply/freeze" 13 | "github.com/parkr/auto-reply/sentry" 14 | ) 15 | 16 | var desiredLabels = []*github.Label{ 17 | {Name: github.String("accepted"), Color: github.String("4bc865")}, 18 | {Name: github.String("bug"), Color: github.String("d41313")}, 19 | {Name: github.String("discussion"), Color: github.String("006b75")}, 20 | {Name: github.String("documentation"), Color: github.String("006b75")}, 21 | {Name: github.String("enhancement"), Color: github.String("009800")}, 22 | {Name: github.String("feature"), Color: github.String("009800")}, 23 | {Name: github.String("fix"), Color: github.String("eb6420")}, 24 | {Name: github.String(freeze.LabelName), Color: github.String("0052cc")}, 25 | {Name: github.String("github"), Color: github.String("222222")}, 26 | {Name: github.String("has-pull-request"), Color: github.String("fbca04")}, 27 | {Name: github.String("help-wanted"), Color: github.String("fbca04")}, 28 | {Name: github.String("internal"), Color: github.String("ededed")}, 29 | {Name: github.String("needs-documentation"), Color: github.String("b72243")}, 30 | {Name: github.String("needs-tests"), Color: github.String("5140a5")}, 31 | {Name: github.String("not-reproduced"), Color: github.String("ba1771")}, 32 | {Name: github.String("pending-feedback"), Color: github.String("fbca04")}, 33 | {Name: github.String("pending-rebase"), Color: github.String("eb6420")}, 34 | {Name: github.String("pinned"), Color: github.String("f3f4d3")}, 35 | {Name: github.String("priority 1 (must)"), Color: github.String("222222")}, 36 | {Name: github.String("priority 2 (should)"), Color: github.String("888888")}, 37 | {Name: github.String("priority 3 (could)"), Color: github.String("cccccc")}, 38 | {Name: github.String("priority 4 (maybe)"), Color: github.String("efefef")}, 39 | {Name: github.String("release"), Color: github.String("d4c5f9")}, 40 | {Name: github.String("security"), Color: github.String("e11d21")}, 41 | {Name: github.String("stale"), Color: github.String("bfd4f2")}, 42 | {Name: github.String("suggestion"), Color: github.String("0052cc")}, 43 | {Name: github.String("support"), Color: github.String("5319e7")}, 44 | {Name: github.String("tests"), Color: github.String("d4c5f9")}, 45 | {Name: github.String("undetermined"), Color: github.String("fe3868")}, 46 | {Name: github.String("ux"), Color: github.String("006b75")}, 47 | {Name: github.String("windows"), Color: github.String("fbca04")}, 48 | {Name: github.String("wont-fix"), Color: github.String("e11d21")}, 49 | } 50 | var listOpts = github.ListOptions{PerPage: 100} 51 | 52 | func allPossibleNames(name string) []string { 53 | return []string{ 54 | name, 55 | strings.Replace(name, "-", "", -1), 56 | strings.Replace(name, "-", " ", -1), 57 | strings.ToLower(name), 58 | strings.Title(name), 59 | strings.Title(strings.Replace(name, "-", "", -1)), 60 | strings.Title(strings.Replace(name, "-", " ", -1)), 61 | } 62 | } 63 | 64 | func findLabel(labels []*github.Label, desiredLabel *github.Label) *github.Label { 65 | possibleNames := allPossibleNames(*desiredLabel.Name) 66 | for _, possibleName := range possibleNames { 67 | for _, label := range labels { 68 | if *label.Name == possibleName { 69 | return label 70 | } 71 | } 72 | } 73 | 74 | return nil 75 | } 76 | 77 | func processRepo(context *ctx.Context, repo *github.Repository, perform bool) error { 78 | owner, repoName := *repo.Owner.Login, *repo.Name 79 | context.Log("Processing %s", *repo.FullName) 80 | 81 | // 1. Find labels on GitHub. 82 | labels, _, err := context.GitHub.Issues.ListLabels(context.Context(), owner, repoName, &listOpts) 83 | if err != nil { 84 | return context.NewError("error fetching labels for %s: %v", *repo.FullName, err) 85 | } 86 | 87 | for _, desiredLabel := range desiredLabels { 88 | matchedLabel := findLabel(labels, desiredLabel) 89 | 90 | // It doesn't exist. Create and continue. 91 | if matchedLabel == nil { 92 | if perform { 93 | context.Log("%s: creating %s with color %s", *repo.FullName, *desiredLabel.Name, *desiredLabel.Color) 94 | _, _, err := context.GitHub.Issues.CreateLabel(context.Context(), owner, repoName, desiredLabel) 95 | if err != nil { 96 | return context.NewError("error creating '%s' for %s: %v", *desiredLabel.Name, *repo.FullName, err) 97 | } 98 | } else { 99 | context.Log("%s: would create %s with color %s", *repo.FullName, *desiredLabel.Name, *desiredLabel.Color) 100 | } 101 | continue 102 | } 103 | 104 | // It does exist, but possibly with incorrect info. Update it. 105 | if *matchedLabel.Name != *desiredLabel.Name || *matchedLabel.Color != *desiredLabel.Color { 106 | if perform { 107 | context.Log("%s: updating %s with data: %v", 108 | *repo.FullName, *matchedLabel.Name, github.Stringify(desiredLabel)) 109 | _, _, err := context.GitHub.Issues.EditLabel(context.Context(), owner, repoName, *matchedLabel.Name, desiredLabel) 110 | if err != nil { 111 | return context.NewError("%s: error updating '%s': %v", *repo.FullName, *matchedLabel.Name, err) 112 | } 113 | } else { 114 | context.Log("%s: would update %s with data: %v", *repo.FullName, *matchedLabel.Name, github.Stringify(desiredLabel)) 115 | } 116 | continue 117 | } 118 | } 119 | 120 | return nil 121 | } 122 | 123 | func main() { 124 | var perform bool 125 | flag.BoolVar(&perform, "f", false, "Whether to modify the labels (if true) or show dry-run output (if false).") 126 | flag.Parse() 127 | 128 | context := ctx.NewDefaultContext() 129 | 130 | log.SetPrefix("freeze-ancient-issues: ") 131 | 132 | sentryClient, err := sentry.NewClient(map[string]string{ 133 | "app": "unify-labels", 134 | "perform": fmt.Sprintf("%t", perform), 135 | }) 136 | if err != nil { 137 | panic(err) 138 | } 139 | 140 | sentryClient.Recover(func() error { 141 | repos, _, err := context.GitHub.Repositories.List(context.Context(), "jekyll", &github.RepositoryListOptions{ 142 | Type: "owner", Sort: "full_name", Direction: "asc", ListOptions: listOpts, 143 | }) 144 | if err != nil { 145 | return err 146 | } 147 | 148 | for _, repo := range repos { 149 | if repo.GetArchived() { 150 | continue // skip archived repos 151 | } 152 | if err := processRepo(context, repo, perform); err != nil { 153 | context.Log("%s: failed!", *repo.FullName) 154 | return err 155 | } 156 | } 157 | 158 | return nil 159 | }) 160 | } 161 | -------------------------------------------------------------------------------- /common/common.go: -------------------------------------------------------------------------------- 1 | // common is a library I made because I was lazy and didn't know better. 2 | package common 3 | 4 | import ( 5 | "fmt" 6 | "net/http" 7 | 8 | "github.com/google/go-github/github" 9 | ) 10 | 11 | func SliceLookup(data []string) map[string]bool { 12 | mapping := map[string]bool{} 13 | for _, datum := range data { 14 | mapping[datum] = true 15 | } 16 | return mapping 17 | } 18 | 19 | func ErrorFromResponse(res *github.Response, err error) error { 20 | if err != nil { 21 | return err 22 | } 23 | 24 | if res.StatusCode >= http.StatusBadRequest { 25 | return fmt.Errorf("unexpected error code: %d", res.StatusCode) 26 | } 27 | 28 | return nil 29 | } 30 | -------------------------------------------------------------------------------- /common/common_test.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | "testing" 7 | 8 | "github.com/google/go-github/github" 9 | ) 10 | 11 | func TestErrorFromResponse(t *testing.T) { 12 | transportErr := errors.New("Something terrible happened") 13 | resError := errors.New("unexpected error code: 404") 14 | httpRes := &http.Response{StatusCode: http.StatusNotFound} 15 | res := &github.Response{Response: httpRes} 16 | 17 | err := ErrorFromResponse(res, transportErr) 18 | if err != transportErr { 19 | t.Fatalf("expected %v, got: %v", transportErr, err) 20 | } 21 | 22 | err = ErrorFromResponse(res, nil) 23 | if err.Error() != resError.Error() { 24 | t.Fatalf("expected %v, got: %v", resError, err) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /ctx/context.go: -------------------------------------------------------------------------------- 1 | // ctx is magic; it is basically my own "context" package before I realized that "context" existed. 2 | // ctx.Context is the main construct. It keeps track of information pertinent to the request. 3 | // It should all eventually be replaced by context.Context from the Go stdlib. 4 | package ctx 5 | 6 | import ( 7 | gocontext "context" 8 | "fmt" 9 | "log" 10 | 11 | "github.com/DataDog/datadog-go/statsd" 12 | "github.com/google/go-github/github" 13 | ) 14 | 15 | type Context struct { 16 | GitHub *github.Client 17 | Statsd *statsd.Client 18 | RubyGems *rubyGemsClient 19 | Repo repoRef 20 | Issue issueRef 21 | 22 | currentlyAuthedGitHubUser *github.User 23 | } 24 | 25 | func (c *Context) NewError(format string, args ...interface{}) error { 26 | c.Log(format, args...) 27 | return fmt.Errorf(format, args...) 28 | } 29 | 30 | func (c *Context) Log(format string, args ...interface{}) { 31 | log.Println(fmt.Sprintf(format, args...)) 32 | } 33 | 34 | func (c *Context) Context() gocontext.Context { 35 | return gocontext.Background() 36 | } 37 | 38 | func NewDefaultContext() *Context { 39 | return &Context{ 40 | GitHub: NewClient(), 41 | Statsd: NewStatsd(), 42 | RubyGems: NewRubyGemsClient(), 43 | } 44 | } 45 | 46 | func WithIssue(owner, repo string, num int) *Context { 47 | context := NewDefaultContext() 48 | context.SetRepo(owner, repo) 49 | context.SetIssue(owner, repo, num) 50 | return context 51 | } 52 | 53 | func WithRepo(owner, repo string) *Context { 54 | context := NewDefaultContext() 55 | context.SetRepo(owner, repo) 56 | return context 57 | } 58 | -------------------------------------------------------------------------------- /ctx/github.go: -------------------------------------------------------------------------------- 1 | package ctx 2 | 3 | import ( 4 | "log" 5 | "os" 6 | 7 | "github.com/google/go-github/github" 8 | "golang.org/x/oauth2" 9 | ) 10 | 11 | const githubAccessTokenEnvVar = "GITHUB_ACCESS_TOKEN" 12 | 13 | func (c *Context) GitHubAuthedAs(login string) bool { 14 | return c.CurrentlyAuthedGitHubUser().GetLogin() == login 15 | } 16 | 17 | func (c *Context) CurrentlyAuthedGitHubUser() *github.User { 18 | if c.currentlyAuthedGitHubUser == nil { 19 | currentlyAuthedUser, _, err := c.GitHub.Users.Get(c.Context(), "") 20 | if err != nil { 21 | c.Log("couldn't fetch currently-auth'd user: %v", err) 22 | return nil 23 | } 24 | c.currentlyAuthedGitHubUser = currentlyAuthedUser 25 | } 26 | 27 | return c.currentlyAuthedGitHubUser 28 | } 29 | 30 | func GitHubToken() string { 31 | return os.Getenv(githubAccessTokenEnvVar) 32 | } 33 | 34 | func NewClient() *github.Client { 35 | if token := GitHubToken(); token != "" { 36 | return github.NewClient(oauth2.NewClient( 37 | oauth2.NoContext, 38 | oauth2.StaticTokenSource( 39 | &oauth2.Token{AccessToken: GitHubToken()}, 40 | ), 41 | )) 42 | } else { 43 | log.Fatalf("%s required", githubAccessTokenEnvVar) 44 | return nil 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /ctx/issue_ref.go: -------------------------------------------------------------------------------- 1 | package ctx 2 | 3 | import "fmt" 4 | 5 | // issueRef is used to refer to an issue or pull request 6 | type issueRef struct { 7 | Author string 8 | Owner, Repo string 9 | Num int 10 | } 11 | 12 | func (r issueRef) String() string { 13 | if r.Num < 0 { 14 | return fmt.Sprintf("%s/%s", r.Owner, r.Repo) 15 | } else { 16 | return fmt.Sprintf("%s/%s#%d", r.Owner, r.Repo, r.Num) 17 | } 18 | } 19 | 20 | func (r issueRef) IsEmpty() bool { 21 | return r.Owner == "" || r.Repo == "" || r.Num == 0 22 | } 23 | 24 | func (c *Context) SetIssue(owner, repo string, num int) { 25 | c.Issue = issueRef{ 26 | Owner: owner, 27 | Repo: repo, 28 | Num: num, 29 | } 30 | } 31 | 32 | func (c *Context) SetAuthor(author string) { 33 | c.Issue.Author = author 34 | } 35 | -------------------------------------------------------------------------------- /ctx/repo_ref.go: -------------------------------------------------------------------------------- 1 | package ctx 2 | 3 | import "fmt" 4 | 5 | type repoRef struct { 6 | Owner string 7 | Name string 8 | } 9 | 10 | func (r repoRef) String() string { 11 | return fmt.Sprintf("%s/%s", r.Owner, r.Name) 12 | } 13 | 14 | func (r repoRef) IsEmpty() bool { 15 | return r.Owner == "" || r.Name == "" 16 | } 17 | 18 | func (c *Context) SetRepo(owner, repo string) { 19 | c.Repo = repoRef{ 20 | Owner: owner, 21 | Name: repo, 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /ctx/rubygems.go: -------------------------------------------------------------------------------- 1 | package ctx 2 | 3 | import ( 4 | "github.com/golang/groupcache/lru" 5 | "github.com/jekyll/dashboard" 6 | ) 7 | 8 | type rubyGemsClient struct { 9 | authToken string 10 | baseAPIUrl string 11 | cache *lru.Cache 12 | } 13 | 14 | func NewRubyGemsClient() *rubyGemsClient { 15 | return &rubyGemsClient{ 16 | baseAPIUrl: "https://rubygems.org/api/v1", 17 | cache: lru.New(500), 18 | } 19 | } 20 | 21 | func (c *rubyGemsClient) GetGem(gemName string) (*dashboard.RubyGem, error) { 22 | if val, ok := c.cache.Get(gemName); ok { 23 | return val.(*dashboard.RubyGem), nil 24 | } 25 | 26 | return dashboard.GetRubyGem(gemName) 27 | } 28 | 29 | func (c *rubyGemsClient) GetLatestVersion(gemName string) (string, error) { 30 | gem, err := c.GetGem(gemName) 31 | if err != nil { 32 | return "", err 33 | } 34 | return gem.Version, nil 35 | } 36 | -------------------------------------------------------------------------------- /ctx/statsd.go: -------------------------------------------------------------------------------- 1 | package ctx 2 | 3 | import ( 4 | "log" 5 | 6 | "github.com/DataDog/datadog-go/statsd" 7 | ) 8 | 9 | var ( 10 | hostport string = "127.0.0.1:8125" 11 | namespace string = "autoreply." 12 | countRate float64 = 1 13 | ) 14 | 15 | func NewStatsd() *statsd.Client { 16 | client, err := statsd.New(hostport) 17 | if err != nil { 18 | log.Fatal(err) 19 | return nil 20 | } 21 | client.Namespace = namespace 22 | return client 23 | } 24 | 25 | func (c *Context) IncrStat(name string, tags []string) { 26 | c.CountStat(name, 1, tags) 27 | } 28 | 29 | func (c *Context) CountStat(name string, value int64, tags []string) { 30 | if c.Statsd != nil { 31 | c.Statsd.Count(name, value, tags, countRate) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /dependencies/dependencies.go: -------------------------------------------------------------------------------- 1 | package dependencies 2 | 3 | import ( 4 | "bufio" 5 | "encoding/base64" 6 | "log" 7 | "regexp" 8 | "strings" 9 | 10 | "github.com/hashicorp/go-version" 11 | "github.com/parkr/auto-reply/ctx" 12 | ) 13 | 14 | var ( 15 | dependencyCache = map[string]Dependency{} 16 | 17 | gemNameRegexp = `(\(|\s+)("|')([\w-_]+)("|')` 18 | versionRegexp = `(,\s*("|')(.+)("|')(\)|\s+)?)?` 19 | 20 | gemspecRegexp = regexp.MustCompile(`\.add_(runtime|development)_dependency` + gemNameRegexp + versionRegexp) 21 | gemspecRegexpNameIndex = 4 22 | gemspecRegexpConstraintIndex = 8 23 | 24 | gemfileRegexp = regexp.MustCompile(`gem` + gemNameRegexp + versionRegexp) 25 | gemfileRegexpNameIndex = 3 26 | gemfileRegexpConstraintIndex = 7 27 | ) 28 | 29 | type lineParserFunc func(line string) Dependency 30 | 31 | type Checker interface { 32 | AllOutdatedDependencies(context *ctx.Context) []Dependency 33 | } 34 | 35 | type rubyDependencyChecker struct { 36 | owner, name string 37 | dependencies []Dependency 38 | } 39 | 40 | func (r *rubyDependencyChecker) AllOutdatedDependencies(context *ctx.Context) []Dependency { 41 | err := r.parseFileForDependencies(context, r.name+".gemspec", r.parseGemspecDependency) 42 | if err != nil { 43 | context.Log("dependencies: couldn't parse gemspec for %s/%s: %v", r.owner, r.name, err) 44 | } 45 | 46 | err = r.parseFileForDependencies(context, "Gemfile", r.parseGemfileDependency) 47 | if err != nil { 48 | context.Log("dependencies: couldn't parse gemfile for %s/%s: %v", r.owner, r.name, err) 49 | } 50 | 51 | outdated := []Dependency{} 52 | for _, dep := range r.dependencies { 53 | if dep.IsOutdated(context) { 54 | outdated = append(outdated, dep) 55 | } 56 | } 57 | 58 | return outdated 59 | } 60 | 61 | func (r *rubyDependencyChecker) hasDependency(name string) bool { 62 | for _, dep := range r.dependencies { 63 | if dep.GetName() == name { 64 | return true 65 | } 66 | } 67 | return false 68 | } 69 | 70 | // parseGemspec finds the gemspec for the project and appends any dependencies 71 | // it finds to the list of dependencies stored on the rubyDependencyChecker instance. 72 | func (r *rubyDependencyChecker) parseFileForDependencies(context *ctx.Context, path string, lineParser lineParserFunc) error { 73 | contents := r.fetchFile(context, path) 74 | if contents == "" { 75 | return nil 76 | } 77 | 78 | scanner := bufio.NewScanner(strings.NewReader(contents)) 79 | for scanner.Scan() { 80 | line := scanner.Text() 81 | dependency := lineParser(line) 82 | if dependency != nil && !r.hasDependency(dependency.GetName()) { 83 | r.dependencies = append(r.dependencies, dependency) 84 | } 85 | } 86 | 87 | return scanner.Err() 88 | } 89 | 90 | func (r *rubyDependencyChecker) parseGemspecDependency(line string) Dependency { 91 | match := gemspecRegexp.FindAllStringSubmatch(line, -1) 92 | if len(match) >= 1 && len(match[0]) >= gemspecRegexpConstraintIndex+1 { 93 | name := match[0][gemspecRegexpNameIndex] 94 | constraintStr := match[0][gemspecRegexpConstraintIndex] 95 | 96 | // If there is no constraint, there's no way it could be outdated! 97 | if constraintStr == "" { 98 | return nil 99 | } 100 | 101 | log.Printf("%+v %+v", name, constraintStr) 102 | constraint, err := version.NewConstraint(constraintStr) 103 | if err == nil { 104 | return &RubyDependency{name: name, constraint: constraint} 105 | } else { 106 | log.Printf("dependencies: can't parse constraint %+v for dependency %s", constraintStr, name) 107 | } 108 | } 109 | return nil 110 | } 111 | 112 | func (r *rubyDependencyChecker) parseGemfileDependency(line string) Dependency { 113 | match := gemfileRegexp.FindAllStringSubmatch(line, -1) 114 | if len(match) >= 1 && len(match[0]) >= gemfileRegexpConstraintIndex+1 { 115 | name := match[0][gemfileRegexpNameIndex] 116 | constraintStr := match[0][gemfileRegexpConstraintIndex] 117 | 118 | // If there is no constraint, there's no way it could be outdated! 119 | if constraintStr == "" { 120 | return nil 121 | } 122 | 123 | constraint, err := version.NewConstraint(constraintStr) 124 | if err == nil { 125 | return &RubyDependency{name: name, constraint: constraint} 126 | } else { 127 | log.Printf("dependencies: can't parse constraint %+v for dependency %s", constraintStr, name) 128 | } 129 | } 130 | return nil 131 | } 132 | 133 | func (r *rubyDependencyChecker) fetchFile(context *ctx.Context, path string) string { 134 | contents, _, _, err := context.GitHub.Repositories.GetContents(context.Context(), r.owner, r.name, path, nil) 135 | if err != nil { 136 | context.Log("dependencies: error getting %s from %s/%s: %v", path, r.owner, r.name, err) 137 | return "" 138 | } 139 | return base64Decode(*contents.Content) 140 | } 141 | 142 | func base64Decode(encoded string) string { 143 | decoded, err := base64.StdEncoding.DecodeString(encoded) 144 | if err != nil { 145 | log.Printf("dependencies: error decoding string: %v\n", err) 146 | return "" 147 | } 148 | return string(decoded) 149 | } 150 | 151 | func NewRubyDependencyChecker(repoOwner, repoName string) Checker { 152 | return &rubyDependencyChecker{owner: repoOwner, name: repoName, dependencies: []Dependency{}} 153 | } 154 | -------------------------------------------------------------------------------- /dependencies/dependency.go: -------------------------------------------------------------------------------- 1 | package dependencies 2 | 3 | import ( 4 | "github.com/hashicorp/go-version" 5 | "github.com/parkr/auto-reply/ctx" 6 | ) 7 | 8 | type Dependency interface { 9 | GetName() string // pre-populated upon creation 10 | GetConstraint() version.Constraints // pre-populated upon creation 11 | GetLatestVersion(context *ctx.Context) *version.Version 12 | IsOutdated(context *ctx.Context) bool 13 | } 14 | -------------------------------------------------------------------------------- /dependencies/github.go: -------------------------------------------------------------------------------- 1 | package dependencies 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/google/go-github/github" 8 | "github.com/parkr/auto-reply/ctx" 9 | "github.com/parkr/auto-reply/search" 10 | "github.com/parkr/githubapi/githubsearch" 11 | ) 12 | 13 | func containsSubstring(s, substring string) bool { 14 | return strings.Index(strings.ToLower(s), strings.ToLower(substring)) >= 0 15 | } 16 | 17 | func GitHubUpdateIssueForDependency(context *ctx.Context, repoOwner, repoName string, dependency Dependency) *github.Issue { 18 | query := githubsearch.IssueSearchParameters{ 19 | Repository: &githubsearch.RepositoryName{ 20 | Owner: repoOwner, 21 | Name: repoName, 22 | }, 23 | State: githubsearch.Open, 24 | Scope: githubsearch.TitleScope, 25 | Query: fmt.Sprintf("update %s v%s", dependency.GetName(), dependency.GetLatestVersion(context)), 26 | } 27 | 28 | issues, err := search.GitHubIssues(context, query) 29 | if err != nil { 30 | context.Log("dependencies: couldn't search github: %+v", err) 31 | return nil 32 | } 33 | 34 | for _, issue := range issues { 35 | if containsSubstring(*issue.Title, "update") && containsSubstring(*issue.Title, dependency.GetName()) { 36 | return &issue 37 | } 38 | } 39 | 40 | return nil 41 | } 42 | 43 | func FileGitHubIssueForDependency(context *ctx.Context, repoOwner, repoName string, dependency Dependency) (*github.Issue, error) { 44 | issue, _, err := context.GitHub.Issues.Create(context.Context(), repoOwner, repoName, &github.IssueRequest{ 45 | Title: github.String(fmt.Sprintf( 46 | "Update dependency constraint to allow for %s v%s", 47 | dependency.GetName(), dependency.GetLatestVersion(context), 48 | )), 49 | Body: github.String(fmt.Sprintf( 50 | "Hey there! :wave:\n\nI noticed that the constraint you have for %s doesn't allow for the latest version to be used.\n\nThe constraint I found was `%s`, and the latest version available is `%s`.\n\nCan you look into updating that constraint so our users can use the latest and greatest version? Thanks! :revolving_hearts:", 51 | dependency.GetName(), dependency.GetConstraint(), dependency.GetLatestVersion(context), 52 | )), 53 | Labels: &[]string{"help-wanted", "dependency"}, 54 | }) 55 | return issue, err 56 | } 57 | -------------------------------------------------------------------------------- /dependencies/ruby_dependency.go: -------------------------------------------------------------------------------- 1 | package dependencies 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/hashicorp/go-version" 7 | "github.com/parkr/auto-reply/ctx" 8 | ) 9 | 10 | type RubyDependency struct { 11 | name string 12 | constraint version.Constraints 13 | latest *version.Version 14 | isOutdated *bool 15 | } 16 | 17 | func (d *RubyDependency) String() string { 18 | return fmt.Sprintf( 19 | "name:%+v constraint:%+v latest %+v isOutdated:%v", 20 | d.name, d.constraint, d.latest, *d.isOutdated, 21 | ) 22 | } 23 | 24 | func (d *RubyDependency) GetName() string { 25 | return d.name 26 | } 27 | 28 | func (d *RubyDependency) GetConstraint() version.Constraints { 29 | return d.constraint 30 | } 31 | 32 | func (d *RubyDependency) GetLatestVersion(context *ctx.Context) *version.Version { 33 | if d.latest != nil { 34 | return d.latest 35 | } 36 | 37 | versionStr, err := context.RubyGems.GetLatestVersion(d.name) 38 | if err != nil { 39 | context.Log("dependencies: could not fetch latest version on rubygems for %s: %v", d.name, err) 40 | return nil 41 | } 42 | 43 | ver, err := version.NewVersion(versionStr) 44 | if err != nil { 45 | context.Log("dependencies: could not parse version %+v for %s: %v", versionStr, d.name, err) 46 | return nil 47 | } 48 | 49 | d.latest = ver 50 | return d.latest 51 | } 52 | 53 | func (d *RubyDependency) IsOutdated(context *ctx.Context) bool { 54 | if d.isOutdated != nil { 55 | return *d.isOutdated 56 | } 57 | 58 | latestVersion := d.GetLatestVersion(context) 59 | if latestVersion == nil { 60 | return false 61 | } 62 | 63 | isOutdated := !d.GetConstraint().Check(latestVersion) 64 | d.isOutdated = &isOutdated 65 | return *d.isOutdated 66 | } 67 | -------------------------------------------------------------------------------- /freeze/freeze.go: -------------------------------------------------------------------------------- 1 | package freeze 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/google/go-github/github" 8 | "github.com/parkr/auto-reply/ctx" 9 | ) 10 | 11 | var ( 12 | TooOld = time.Now().Add(-365 * 24 * time.Hour).Format("2006-01-02") 13 | LabelName = "frozen-due-to-age" 14 | ) 15 | 16 | func AllTooOldIssues(context *ctx.Context, owner, repo string) ([]github.Issue, error) { 17 | issues := []github.Issue{} 18 | query := fmt.Sprintf("repo:%s/%s is:closed -label:%v updated:<=%s", owner, repo, LabelName, TooOld) 19 | opts := &github.SearchOptions{ 20 | Sort: "created", 21 | Order: "asc", 22 | ListOptions: github.ListOptions{ 23 | PerPage: 500, 24 | }, 25 | } 26 | 27 | for { 28 | result, resp, err := context.GitHub.Search.Issues(context.Context(), query, opts) 29 | if err != nil { 30 | return nil, err 31 | } 32 | 33 | if *result.Total == 0 { 34 | return issues, nil 35 | } 36 | 37 | issues = append(issues, result.Issues...) 38 | 39 | if resp.NextPage == 0 { 40 | break 41 | } 42 | opts.ListOptions.Page = resp.NextPage 43 | } 44 | 45 | return issues, nil 46 | } 47 | 48 | func Freeze(context *ctx.Context, owner, repo string, issueNum int) error { 49 | _, err := context.GitHub.Issues.Lock(context.Context(), owner, repo, issueNum, nil) 50 | if err != nil { 51 | return err 52 | } 53 | _, _, err = context.GitHub.Issues.AddLabelsToIssue(context.Context(), owner, repo, issueNum, []string{LabelName}) 54 | return err 55 | } 56 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/parkr/auto-reply 2 | 3 | go 1.14 4 | 5 | require ( 6 | github.com/DataDog/datadog-go v0.0.0-20180330214955-e67964b4021a 7 | github.com/certifi/gocertifi v0.0.0-20180118203423-deb3ae2ef261 // indirect 8 | github.com/getsentry/raven-go v0.2.0 9 | github.com/golang/groupcache v0.0.0-20181024230925-c65c006176ff 10 | github.com/google/go-github v17.0.0+incompatible 11 | github.com/google/go-querystring v1.0.0 // indirect 12 | github.com/hashicorp/go-version v1.6.0 13 | github.com/heroku/x v0.0.0-20181101225318-084eff478eb7 14 | github.com/jekyll/dashboard v0.0.0-20181102184910-a67d1fab1794 15 | github.com/parkr/changelog v0.0.0-20160308230713-cef0141074f9 16 | github.com/parkr/githubapi v0.0.0-20171101210150-a4a24abadc26 17 | github.com/pkg/errors v0.8.0 // indirect 18 | github.com/stretchr/testify v1.8.1 19 | golang.org/x/net v0.0.0-20181102091132-c10e9556a7bc // indirect 20 | golang.org/x/oauth2 v0.0.0-20181102170140-232e45548389 21 | golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f 22 | google.golang.org/appengine v1.3.0 // indirect 23 | ) 24 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/DataDog/datadog-go v0.0.0-20180330214955-e67964b4021a h1:zpQSzEApXM0qkXcpdjeJ4OpnBWhD/X8zT/iT1wYLiVU= 2 | github.com/DataDog/datadog-go v0.0.0-20180330214955-e67964b4021a/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ= 3 | github.com/certifi/gocertifi v0.0.0-20180118203423-deb3ae2ef261 h1:6/yVvBsKeAw05IUj4AzvrxaCnDjN4nUqKjW9+w5wixg= 4 | github.com/certifi/gocertifi v0.0.0-20180118203423-deb3ae2ef261/go.mod h1:GJKEexRPVJrBSOjoqN5VNOIKJ5Q3RViH6eu3puDRwx4= 5 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 6 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 7 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 8 | github.com/getsentry/raven-go v0.2.0 h1:no+xWJRb5ZI7eE8TWgIq1jLulQiIoLG0IfYxv5JYMGs= 9 | github.com/getsentry/raven-go v0.2.0/go.mod h1:KungGk8q33+aIAZUIVWZDr2OfAEBsO49PX4NzFV5kcQ= 10 | github.com/golang/groupcache v0.0.0-20181024230925-c65c006176ff h1:kOkM9whyQYodu09SJ6W3NCsHG7crFaJILQ22Gozp3lg= 11 | github.com/golang/groupcache v0.0.0-20181024230925-c65c006176ff/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= 12 | github.com/golang/protobuf v1.2.0 h1:P3YflyNX/ehuJFLhxviNdFxQPkGK5cDcApsge1SqnvM= 13 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 14 | github.com/google/go-github v17.0.0+incompatible h1:N0LgJ1j65A7kfXrZnUDaYCs/Sf4rEjNlfyDHW9dolSY= 15 | github.com/google/go-github v17.0.0+incompatible/go.mod h1:zLgOLi98H3fifZn+44m+umXrS52loVEgC2AApnigrVQ= 16 | github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk= 17 | github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= 18 | github.com/hashicorp/go-version v1.6.0 h1:feTTfFNnjP967rlCxM/I9g701jU+RN74YKx2mOkIeek= 19 | github.com/hashicorp/go-version v1.6.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= 20 | github.com/heroku/x v0.0.0-20181101225318-084eff478eb7 h1:bo0NMNWMGntZNDgT7U+I66PUMzmdrYa1EGPuo0XExtI= 21 | github.com/heroku/x v0.0.0-20181101225318-084eff478eb7/go.mod h1:opmAyjmIGn9/Y+9Nia6eIaktIXIoMhhFXEFbHLMsX3Y= 22 | github.com/jekyll/dashboard v0.0.0-20181102184910-a67d1fab1794 h1:c0LqJCjTppmMKKR+8zlupB1uR78LPRvwwIKVQEWqzLk= 23 | github.com/jekyll/dashboard v0.0.0-20181102184910-a67d1fab1794/go.mod h1:IIwUzoqslsI9cGj1j5GnmSa9/4keJNxnnu8lyZ1RDIs= 24 | github.com/parkr/changelog v0.0.0-20160308230713-cef0141074f9 h1:Ug7uG/R9whrk5Wes8JXQzPDNvBs4BKNYI5j/nG7yMd8= 25 | github.com/parkr/changelog v0.0.0-20160308230713-cef0141074f9/go.mod h1:kRVASmUI2V9DBB5AEvrVzh8lApWQVqG18ezNdCW9zcE= 26 | github.com/parkr/githubapi v0.0.0-20171101210150-a4a24abadc26 h1:ii04pSuH51rHmoUDVJzQI7UhIOzFObPAENIc20gKkP0= 27 | github.com/parkr/githubapi v0.0.0-20171101210150-a4a24abadc26/go.mod h1:GrvgoohXgEyzl/QJo7yDN9zeXImTjrXZVMDIqmKspf0= 28 | github.com/pkg/errors v0.8.0 h1:WdK/asTD0HN+q6hsWO3/vpuAkAr+tw6aNJNDFFf0+qw= 29 | github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 30 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 31 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 32 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 33 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 34 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 35 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 36 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 37 | github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= 38 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 39 | golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 40 | golang.org/x/net v0.0.0-20181102091132-c10e9556a7bc h1:ZMCWScCvS2fUVFw8LOpxyUUW5qiviqr4Dg5NdjLeiLU= 41 | golang.org/x/net v0.0.0-20181102091132-c10e9556a7bc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 42 | golang.org/x/oauth2 v0.0.0-20181102170140-232e45548389 h1:NSr16yuMknNO4kjJ2yNMJBdS55sdwZiWrXbt3fbM3pI= 43 | golang.org/x/oauth2 v0.0.0-20181102170140-232e45548389/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= 44 | golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f h1:wMNYb4v58l5UBM7MYRLPG6ZhfOqbKu7X5eyFl8ZhKvA= 45 | golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 46 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 47 | google.golang.org/appengine v1.3.0 h1:FBSsiFRMz3LBeXIomRnVzrQwSDj4ibvcRexLG0LZGQk= 48 | google.golang.org/appengine v1.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= 49 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 50 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 51 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 52 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 53 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 54 | -------------------------------------------------------------------------------- /hooks/event_types.go: -------------------------------------------------------------------------------- 1 | package hooks 2 | 3 | type EventType string 4 | 5 | var ( 6 | CommitCommentEvent EventType = "commit_comment" 7 | CreateEvent EventType = "create" 8 | DeleteEvent EventType = "delete" 9 | DeploymentEvent EventType = "deployment" 10 | DeploymentStatusEvent EventType = "deployment_status" 11 | ForkEvent EventType = "fork" 12 | GollumEvent EventType = "gollum" 13 | IssueCommentEvent EventType = "issue_comment" 14 | IssuesEvent EventType = "issues" 15 | MemberEvent EventType = "member" 16 | MembershipEvent EventType = "membership" 17 | PageBuildEvent EventType = "page_build" 18 | PublicEvent EventType = "public" 19 | PullRequestEvent EventType = "pull_request" 20 | PullRequestReviewEvent EventType = "pull_request_review" 21 | PullRequestReviewCommentEvent EventType = "pull_request_review_comment" 22 | PushEvent EventType = "push" 23 | ReleaseEvent EventType = "release" 24 | RepositoryEvent EventType = "repository" 25 | StatusEvent EventType = "status" 26 | TeamAddEvent EventType = "team_add" 27 | WatchEvent EventType = "watch" 28 | 29 | pingEvent EventType = "ping" 30 | ) 31 | 32 | func (e EventType) String() string { 33 | return string(e) 34 | } 35 | 36 | type pingEventPayload struct { 37 | Zen string `json:"zen"` 38 | } 39 | -------------------------------------------------------------------------------- /hooks/global_handler.go: -------------------------------------------------------------------------------- 1 | package hooks 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "log" 7 | "net/http" 8 | "os" 9 | 10 | "github.com/google/go-github/github" 11 | "github.com/parkr/auto-reply/ctx" 12 | ) 13 | 14 | type EventHandlerMap map[EventType][]EventHandler 15 | 16 | func (m EventHandlerMap) AddHandler(eventType EventType, handler EventHandler) { 17 | if m[eventType] == nil { 18 | m[eventType] = []EventHandler{} 19 | } 20 | 21 | m[eventType] = append(m[eventType], handler) 22 | } 23 | 24 | // GlobalHandler is a handy handler which can take in every event, 25 | // choose which handlers to fire, and fires them. 26 | type GlobalHandler struct { 27 | Context *ctx.Context 28 | EventHandlers EventHandlerMap 29 | 30 | // secret is the secret used by GitHub to validate the integrity of the 31 | // request. It is given to GitHub in the webhook management interface. 32 | secret []byte 33 | } 34 | 35 | // ServeHTTP handles the incoming HTTP request, validates the payload and 36 | // fires 37 | func (h *GlobalHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 38 | w.Header().Set("Content-Type", "text/plain") 39 | 40 | var payload []byte 41 | var err error 42 | if secret := h.getSecret(); len(secret) > 0 { 43 | payload, err = github.ValidatePayload(r, secret) 44 | if err != nil { 45 | log.Println("received invalid signature:", err) 46 | http.Error(w, err.Error(), http.StatusForbidden) 47 | return 48 | } 49 | } else { 50 | err = json.NewDecoder(r.Body).Decode(&payload) 51 | if err != nil { 52 | log.Println("received invalid json in body:", err) 53 | http.Error(w, err.Error(), http.StatusBadRequest) 54 | return 55 | } 56 | } 57 | h.HandlePayload(w, r, payload) 58 | } 59 | 60 | // HandlePayload handles the actual unpacking of the payload and firing of the proper handlers. 61 | // It will never respond with anything but a 200. 62 | func (h *GlobalHandler) HandlePayload(w http.ResponseWriter, r *http.Request, payload []byte) { 63 | eventType := github.WebHookType(r) 64 | 65 | if eventType == "ping" { 66 | handlePingPayload(w, r, payload) 67 | return 68 | } 69 | 70 | if os.Getenv("AUTO_REPLY_DEBUG") == "true" { 71 | log.Printf("payload: %s %s", eventType, string(payload)) 72 | } 73 | 74 | if handlers, ok := h.EventHandlers[EventType(eventType)]; ok { 75 | numHandlers := h.FireHandlers(handlers, eventType, payload) 76 | 77 | if EventType(eventType) == PullRequestEvent { 78 | if issueCommentHandlers, ok := h.EventHandlers[EventType(eventType)]; ok { 79 | numHandlers += h.FireHandlers(issueCommentHandlers, "issue_comment", payload) 80 | } 81 | } 82 | 83 | fmt.Fprintf(w, "fired %d handlers", numHandlers) 84 | } else { 85 | h.Context.IncrStat("handler.invalid", nil) 86 | errMessage := fmt.Sprintf("unhandled event type: %s", eventType) 87 | log.Printf("%s; handled events: %+v", errMessage, h.AcceptedEventTypes()) 88 | http.Error(w, errMessage, 200) 89 | } 90 | 91 | return 92 | } 93 | 94 | func (h *GlobalHandler) FireHandlers(handlers []EventHandler, eventType string, payload []byte) int { 95 | h.Context.IncrStat("handler."+eventType, nil) 96 | event, err := github.ParseWebHook(eventType, payload) 97 | if err != nil { 98 | h.Context.NewError("FireHandlers: couldn't parse webhook: %+v", err) 99 | return 0 100 | } 101 | for _, handler := range handlers { 102 | go handler(h.Context, event) 103 | } 104 | return len(handlers) 105 | } 106 | 107 | // AcceptedEventTypes returns an array of all event types the GlobalHandler 108 | // can accept. 109 | func (h *GlobalHandler) AcceptedEventTypes() []EventType { 110 | keys := []EventType{} 111 | for k := range h.EventHandlers { 112 | keys = append(keys, k) 113 | } 114 | return keys 115 | } 116 | 117 | func (h *GlobalHandler) getSecret() []byte { 118 | if len(h.secret) > 0 { 119 | return h.secret 120 | } 121 | 122 | // Fill the value of h.secret if one exists. 123 | if envVal := os.Getenv("GITHUB_WEBHOOK_SECRET"); envVal != "" { 124 | h.secret = []byte(envVal) 125 | } 126 | return h.secret 127 | } 128 | 129 | func handlePingPayload(w http.ResponseWriter, r *http.Request, payload []byte) { 130 | var ping pingEventPayload 131 | if err := json.Unmarshal(payload, &ping); err != nil { 132 | log.Println(string(payload)) 133 | http.Error(w, "you sure that was a ping message?", 500) 134 | log.Printf("GlobalHandler.HandlePayload: couldn't handle ping payload: %+v", err) 135 | return 136 | } 137 | http.Error(w, ping.Zen, 200) 138 | } 139 | -------------------------------------------------------------------------------- /hooks/handler.go: -------------------------------------------------------------------------------- 1 | package hooks 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/parkr/auto-reply/ctx" 7 | ) 8 | 9 | // HookHandler describes the interface for any type which can handle a webhook payload. 10 | type HookHandler interface { 11 | HandlePayload(w http.ResponseWriter, r *http.Request, payload []byte) 12 | } 13 | 14 | // EventHandler is An event handler takes in a given event and operates on it. 15 | type EventHandler func(context *ctx.Context, event interface{}) error 16 | -------------------------------------------------------------------------------- /hooks/hooks.go: -------------------------------------------------------------------------------- 1 | // hooks is the entrypoint for this project: it handles the GitHub webhooks 2 | // and funnels the data into the various action functions registered to it. 3 | package hooks 4 | -------------------------------------------------------------------------------- /hooks/repo.go: -------------------------------------------------------------------------------- 1 | package hooks 2 | 3 | type Repo struct { 4 | Owner, Name string 5 | } 6 | -------------------------------------------------------------------------------- /jekyll/deprecate/deprecate.go: -------------------------------------------------------------------------------- 1 | // deprecate closes issues on deprecated repos and leaves a nice comment for the user. 2 | package deprecate 3 | 4 | import ( 5 | "fmt" 6 | 7 | "github.com/google/go-github/github" 8 | "github.com/parkr/auto-reply/ctx" 9 | ) 10 | 11 | var ( 12 | deprecatedRepos = map[string]string{ 13 | "jekyll/jekyll-help": `This repository is no longer maintained. If you're still experiencing this problem, please search for your issue on [Jekyll Talk](https://talk.jekyllrb.com/), our new community forum. If it isn't there, feel free to post to the Help category and someone will assist you. Thanks!`, 14 | } 15 | ) 16 | 17 | func DeprecateOldRepos(context *ctx.Context, event interface{}) error { 18 | issue, ok := event.(*github.IssuesEvent) 19 | if !ok { 20 | return context.NewError("DeprecateOldRepos: not an issue event") 21 | } 22 | 23 | if *issue.Action != "opened" { 24 | return context.NewError("DeprecateOldRepos: issue event's action is not 'opened'") 25 | } 26 | 27 | owner, name, number := *issue.Repo.Owner.Login, *issue.Repo.Name, *issue.Issue.Number 28 | if message, ok := deprecatedRepos[*issue.Repo.FullName]; ok { 29 | err := commentAndClose(context, owner, name, number, message) 30 | if err != nil { 31 | return err 32 | } 33 | } 34 | 35 | return nil 36 | } 37 | 38 | func commentAndClose(context *ctx.Context, owner, name string, number int, message string) error { 39 | ref := fmt.Sprintf("%s/%s#%d", owner, name, number) 40 | _, _, err := context.GitHub.Issues.CreateComment( 41 | context.Context(), 42 | owner, name, number, 43 | &github.IssueComment{Body: github.String(message)}, 44 | ) 45 | if err != nil { 46 | return context.NewError("DeprecateOldRepos: error commenting on %s: %v", ref, err) 47 | } 48 | _, _, err = context.GitHub.Issues.Edit( 49 | context.Context(), 50 | owner, name, number, 51 | &github.IssueRequest{State: github.String("closed")}, 52 | ) 53 | if err != nil { 54 | return context.NewError("DeprecateOldRepos: error closing %s: %v", ref, err) 55 | } 56 | return nil 57 | } 58 | -------------------------------------------------------------------------------- /jekyll/issuecomment/issuecomment.go: -------------------------------------------------------------------------------- 1 | // issuecomment has 2 handlers: one to unlabel stale issues, and one to 2 | // remove the pending-feedback label. 3 | package issuecomment 4 | -------------------------------------------------------------------------------- /jekyll/issuecomment/pending_feedback_unlabeler.go: -------------------------------------------------------------------------------- 1 | package issuecomment 2 | 3 | import ( 4 | "github.com/google/go-github/github" 5 | "github.com/parkr/auto-reply/ctx" 6 | "github.com/parkr/auto-reply/labeler" 7 | ) 8 | 9 | const pendingFeedbackLabel = "pending-feedback" 10 | 11 | func PendingFeedbackUnlabeler(context *ctx.Context, event interface{}) error { 12 | comment, ok := event.(*github.IssueCommentEvent) 13 | if !ok { 14 | return context.NewError("PendingFeedbackUnlabeler: not an issue comment event") 15 | } 16 | 17 | if senderAndCreatorEqual(comment) && hasLabel(comment.Issue.Labels, pendingFeedbackLabel) { 18 | owner, name, number := *comment.Repo.Owner.Login, *comment.Repo.Name, *comment.Issue.Number 19 | err := labeler.RemoveLabelIfExists(context, owner, name, number, pendingFeedbackLabel) 20 | if err != nil { 21 | return context.NewError("PendingFeedbackUnlabeler: error removing label on %s/%s#%d: %v", owner, name, number, err) 22 | } 23 | } 24 | 25 | return nil 26 | } 27 | 28 | func senderAndCreatorEqual(event *github.IssueCommentEvent) bool { 29 | return event.Sender != nil && event.Issue != nil && event.Issue.User != nil && *event.Sender.ID == *event.Issue.User.ID 30 | } 31 | 32 | func hasLabel(labels []github.Label, desiredLabel string) bool { 33 | for _, label := range labels { 34 | if *label.Name == desiredLabel { 35 | return true 36 | } 37 | } 38 | return false 39 | } 40 | -------------------------------------------------------------------------------- /jekyll/issuecomment/stale_unlabeler.go: -------------------------------------------------------------------------------- 1 | package issuecomment 2 | 3 | import ( 4 | "github.com/google/go-github/github" 5 | "github.com/parkr/auto-reply/ctx" 6 | "github.com/parkr/auto-reply/labeler" 7 | ) 8 | 9 | func StaleUnlabeler(context *ctx.Context, event interface{}) error { 10 | comment, ok := event.(*github.IssueCommentEvent) 11 | if !ok { 12 | return context.NewError("StaleUnlabeler: not an issue comment event") 13 | } 14 | 15 | if *comment.Action != "created" { 16 | return nil 17 | } 18 | 19 | if context.GitHubAuthedAs(*comment.Sender.Login) { 20 | return nil // heh. 21 | } 22 | 23 | owner, name, number := *comment.Repo.Owner.Login, *comment.Repo.Name, *comment.Issue.Number 24 | err := labeler.RemoveLabelIfExists(context, owner, name, number, "stale") 25 | if err != nil { 26 | return context.NewError("StaleUnlabeler: error removing label on %s/%s#%d: %v", owner, name, number, err) 27 | } 28 | 29 | return nil 30 | } 31 | -------------------------------------------------------------------------------- /jekyll/jekyll.go: -------------------------------------------------------------------------------- 1 | // jekyll is the configuration of handlers and such specific to the org's requirements. This is what you should copy and customize. 2 | package jekyll 3 | 4 | import ( 5 | "fmt" 6 | 7 | "github.com/parkr/auto-reply/affinity" 8 | "github.com/parkr/auto-reply/autopull" 9 | "github.com/parkr/auto-reply/chlog" 10 | "github.com/parkr/auto-reply/ctx" 11 | "github.com/parkr/auto-reply/hooks" 12 | "github.com/parkr/auto-reply/labeler" 13 | "github.com/parkr/auto-reply/lgtm" 14 | "github.com/parkr/auto-reply/travis" 15 | 16 | "github.com/google/go-github/github" 17 | "github.com/parkr/auto-reply/jekyll/deprecate" 18 | "github.com/parkr/auto-reply/jekyll/issuecomment" 19 | ) 20 | 21 | var jekyllOrgEventHandlers = hooks.EventHandlerMap{ 22 | hooks.CreateEvent: {chlog.CreateReleaseOnTagHandler}, 23 | hooks.IssuesEvent: {deprecate.DeprecateOldRepos}, 24 | hooks.IssueCommentEvent: { 25 | issuecomment.PendingFeedbackUnlabeler, 26 | issuecomment.StaleUnlabeler, 27 | chlog.MergeAndLabel, 28 | }, 29 | hooks.PullRequestEvent: { 30 | labeler.IssueHasPullRequestLabeler, 31 | labeler.PendingRebaseNeedsWorkPRUnlabeler, 32 | }, 33 | hooks.ReleaseEvent: {chlog.CloseMilestoneOnRelease}, 34 | hooks.StatusEvent: {statStatus, travis.FailingFmtBuildHandler}, 35 | } 36 | 37 | func statStatus(context *ctx.Context, payload interface{}) error { 38 | status, ok := payload.(*github.StatusEvent) 39 | if !ok { 40 | return context.NewError("statStatus: not an status event") 41 | } 42 | 43 | context.SetIssue(*status.Repo.Owner.Login, *status.Repo.Name, -1) 44 | 45 | if context.Statsd != nil { 46 | statName := fmt.Sprintf("status.%s", *status.State) 47 | context.Log("context.Statsd.Count(%s, 1, []string{context:%s, repo:%s}, 1)", statName, *status.Context, context.Issue) 48 | return context.Statsd.Incr( 49 | statName, 50 | []string{ 51 | "context:" + *status.Context, 52 | "repo:" + context.Issue.String(), 53 | }, 54 | float64(1.0), // rate..? 55 | ) 56 | } 57 | return nil 58 | } 59 | 60 | func jekyllAffinityHandler(context *ctx.Context) *affinity.Handler { 61 | handler := &affinity.Handler{} 62 | 63 | handler.AddRepo("jekyll", "jekyll") 64 | handler.AddRepo("jekyll", "minima") 65 | 66 | handler.AddTeam(context, 1961060) // @jekyll/build 67 | handler.AddTeam(context, 1961072) // @jekyll/documentation 68 | handler.AddTeam(context, 1961061) // @jekyll/ecosystem 69 | handler.AddTeam(context, 1961065) // @jekyll/performance 70 | handler.AddTeam(context, 1961059) // @jekyll/stability 71 | handler.AddTeam(context, 1116640) // @jekyll/windows 72 | 73 | context.Log("affinity teams: %+v", handler.GetTeams()) 74 | context.Log("affinity team repos: %+v", handler.GetRepos()) 75 | 76 | return handler 77 | } 78 | 79 | func newLgtmHandler() *lgtm.Handler { 80 | handler := &lgtm.Handler{} 81 | 82 | handler.AddRepo("jekyll", "jekyll", 2) 83 | handler.AddRepo("jekyll", "jekyll-coffeescript", 2) 84 | handler.AddRepo("jekyll", "jekyll-compose", 1) 85 | handler.AddRepo("jekyll", "jekyll-commonmark", 1) 86 | handler.AddRepo("jekyll", "jekyll-docs", 1) 87 | handler.AddRepo("jekyll", "jekyll-feed", 1) 88 | handler.AddRepo("jekyll", "jekyll-gist", 2) 89 | handler.AddRepo("jekyll", "jekyll-import", 1) 90 | handler.AddRepo("jekyll", "jekyll-mentions", 2) 91 | handler.AddRepo("jekyll", "jekyll-opal", 2) 92 | handler.AddRepo("jekyll", "jekyll-paginate", 2) 93 | handler.AddRepo("jekyll", "jekyll-redirect-from", 2) 94 | handler.AddRepo("jekyll", "jekyll-sass-converter", 2) 95 | handler.AddRepo("jekyll", "jekyll-seo-tag", 1) 96 | handler.AddRepo("jekyll", "jekyll-sitemap", 2) 97 | handler.AddRepo("jekyll", "jekyll-textile-converter", 2) 98 | handler.AddRepo("jekyll", "jekyll-watch", 2) 99 | handler.AddRepo("jekyll", "github-metadata", 2) 100 | handler.AddRepo("jekyll", "jemoji", 1) 101 | handler.AddRepo("jekyll", "mercenary", 1) 102 | handler.AddRepo("jekyll", "minima", 1) 103 | handler.AddRepo("jekyll", "directory", 1) 104 | 105 | return handler 106 | } 107 | 108 | func NewJekyllOrgHandler(context *ctx.Context) *hooks.GlobalHandler { 109 | affinityHandler := jekyllAffinityHandler(context) 110 | jekyllOrgEventHandlers.AddHandler(hooks.IssuesEvent, affinityHandler.AssignIssueToAffinityTeamCaptain) 111 | jekyllOrgEventHandlers.AddHandler(hooks.IssueCommentEvent, affinityHandler.AssignIssueToAffinityTeamCaptainFromComment) 112 | jekyllOrgEventHandlers.AddHandler(hooks.PullRequestEvent, affinityHandler.AssignPRToAffinityTeamCaptain) 113 | jekyllOrgEventHandlers.AddHandler(hooks.PullRequestEvent, affinityHandler.RequestReviewFromAffinityTeamCaptains) 114 | 115 | lgtmHandler := newLgtmHandler() 116 | jekyllOrgEventHandlers.AddHandler(hooks.PullRequestReviewEvent, lgtmHandler.PullRequestReviewHandler) 117 | 118 | autopullHandler := autopull.Handler{} 119 | autopullHandler.AcceptAllRepos(true) 120 | jekyllOrgEventHandlers.AddHandler(hooks.PushEvent, autopullHandler.CreatePullRequestFromPush) 121 | 122 | return &hooks.GlobalHandler{ 123 | Context: context, 124 | EventHandlers: jekyllOrgEventHandlers, 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /jekyll/repos.go: -------------------------------------------------------------------------------- 1 | package jekyll 2 | 3 | type JekyllRepository struct { 4 | name string 5 | } 6 | 7 | // Always the Jekyll org. 8 | func (r JekyllRepository) Owner() string { 9 | return "jekyll" 10 | } 11 | 12 | func (r JekyllRepository) Name() string { 13 | return r.name 14 | } 15 | 16 | // String returns NWO. 17 | func (r JekyllRepository) String() string { 18 | return r.Owner() + "/" + r.Name() 19 | } 20 | 21 | func NewRepository(owner, repo string) Repository { 22 | return GitHubRepository{owner, repo} 23 | } 24 | 25 | type GitHubRepository struct { 26 | owner string 27 | name string 28 | } 29 | 30 | // Always the Jekyll org. 31 | func (r GitHubRepository) Owner() string { 32 | return r.owner 33 | } 34 | 35 | func (r GitHubRepository) Name() string { 36 | return r.name 37 | } 38 | 39 | // String returns NWO. 40 | func (r GitHubRepository) String() string { 41 | return r.Owner() + "/" + r.Name() 42 | } 43 | 44 | type Repository interface { 45 | Owner() string 46 | Name() string 47 | String() string 48 | } 49 | 50 | var DefaultRepos = []Repository{ 51 | JekyllRepository{name: "github-metadata"}, 52 | JekyllRepository{name: "jekyll"}, 53 | JekyllRepository{name: "jekyll-coffeescript"}, 54 | JekyllRepository{name: "jekyll-compose"}, 55 | JekyllRepository{name: "jekyll-feed"}, 56 | JekyllRepository{name: "jekyll-gist"}, 57 | JekyllRepository{name: "jekyll-import"}, 58 | JekyllRepository{name: "jekyll-redirect-from"}, 59 | JekyllRepository{name: "jekyll-sass-converter"}, 60 | JekyllRepository{name: "jekyll-seo-tag"}, 61 | JekyllRepository{name: "jekyll-sitemap"}, 62 | JekyllRepository{name: "jekyll-watch"}, 63 | JekyllRepository{name: "jemoji"}, 64 | JekyllRepository{name: "minima"}, 65 | JekyllRepository{name: "directory"}, 66 | } 67 | -------------------------------------------------------------------------------- /labeler/github.go: -------------------------------------------------------------------------------- 1 | package labeler 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | 7 | "github.com/parkr/auto-reply/common" 8 | "github.com/parkr/auto-reply/ctx" 9 | ) 10 | 11 | func AddLabels(context *ctx.Context, owner, repo string, number int, labels []string) error { 12 | _, res, err := context.GitHub.Issues.AddLabelsToIssue(context.Context(), owner, repo, number, labels) 13 | return common.ErrorFromResponse(res, err) 14 | } 15 | 16 | func RemoveLabel(context *ctx.Context, owner, repo string, number int, label string) error { 17 | res, err := context.GitHub.Issues.RemoveLabelForIssue(context.Context(), owner, repo, number, label) 18 | return common.ErrorFromResponse(res, err) 19 | } 20 | 21 | func RemoveLabels(context *ctx.Context, owner, repo string, number int, labels []string) error { 22 | var anyError error 23 | for _, label := range labels { 24 | err := RemoveLabel(context, owner, repo, number, label) 25 | if err != nil { 26 | anyError = err 27 | log.Printf("couldn't remove label '%s' from %s/%s#%d: %v", label, owner, repo, number, err) 28 | } 29 | } 30 | return anyError 31 | } 32 | 33 | func RemoveLabelIfExists(context *ctx.Context, owner, repo string, number int, label string) error { 34 | if IssueHasLabel(context, owner, repo, number, label) { 35 | return RemoveLabel(context, owner, repo, number, label) 36 | } 37 | return fmt.Errorf("%s/%s#%d doesn't have label: %s", owner, repo, number, label) 38 | } 39 | 40 | func IssueHasLabel(context *ctx.Context, owner, repo string, number int, label string) bool { 41 | labels, res, err := context.GitHub.Issues.ListLabelsByIssue(context.Context(), owner, repo, number, nil) 42 | if err = common.ErrorFromResponse(res, err); err != nil { 43 | return false 44 | } 45 | 46 | for _, issueLabel := range labels { 47 | if *issueLabel.Name == label { 48 | return true 49 | } 50 | } 51 | return false 52 | } 53 | -------------------------------------------------------------------------------- /labeler/has_pull_request.go: -------------------------------------------------------------------------------- 1 | package labeler 2 | 3 | import ( 4 | "regexp" 5 | "strconv" 6 | 7 | "github.com/google/go-github/github" 8 | "github.com/parkr/auto-reply/ctx" 9 | ) 10 | 11 | var fixesIssueMatcher = regexp.MustCompile(`(?i)(?:Close|Closes|Closed|Fix|Fixes|Fixed|Resolve|Resolves|Resolved)\s+#(\d+)`) 12 | 13 | func IssueHasPullRequestLabeler(context *ctx.Context, payload interface{}) error { 14 | event, ok := payload.(*github.PullRequestEvent) 15 | if !ok { 16 | return context.NewError("IssueHasPullRequestLabeler: not a pull request event") 17 | } 18 | 19 | if *event.Action != "opened" { 20 | return nil 21 | } 22 | 23 | owner, repo, description := *event.Repo.Owner.Login, *event.Repo.Name, *event.PullRequest.Body 24 | 25 | issueNums := linkedIssues(description) 26 | if issueNums == nil { 27 | return nil 28 | } 29 | 30 | var err error 31 | for _, issueNum := range issueNums { 32 | err := AddLabels(context, owner, repo, issueNum, []string{"has-pull-request"}) 33 | if err != nil { 34 | context.Log("error adding the has-pull-request label to %s/%s#%d: %v", owner, repo, issueNum, err) 35 | } 36 | } 37 | 38 | return err 39 | } 40 | 41 | func linkedIssues(description string) []int { 42 | issueSubmatches := fixesIssueMatcher.FindAllStringSubmatch(description, -1) 43 | if len(issueSubmatches) == 0 || len(issueSubmatches[0]) < 2 { 44 | return nil 45 | } 46 | 47 | issueNums := []int{} 48 | for _, match := range issueSubmatches { 49 | if len(match) < 2 { 50 | continue 51 | } 52 | 53 | if issueNum, err := strconv.Atoi(match[1]); err == nil { 54 | issueNums = append(issueNums, issueNum) 55 | } 56 | } 57 | 58 | return issueNums 59 | } 60 | -------------------------------------------------------------------------------- /labeler/has_pull_request_test.go: -------------------------------------------------------------------------------- 1 | package labeler 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestLinkedIssues(t *testing.T) { 10 | assert.Equal(t, []int{13, 14}, 11 | linkedIssues("Fixes #13. Fixes #14")) 12 | 13 | assert.Equal(t, []int{13, 14, 1, 412, 2}, 14 | linkedIssues("Fixes #13. Fixes # Resolves #14 Settles #12 Closes #1. Fixes #412..... Close #2")) 15 | 16 | multilineComment := `Upgrade Rubocop to 0.49.0 17 | 18 | Fix #6089 19 | Fix #6101 ` 20 | assert.Equal(t, []int{6089, 6101}, linkedIssues(multilineComment)) 21 | } 22 | 23 | func TestClosedIssueRegex(t *testing.T) { 24 | assert.Equal(t, "13", 25 | fixesIssueMatcher.FindAllStringSubmatch("Fixes #13", -1)[0][1], 26 | "should have extracted 13 from 'Fixes #13'") 27 | 28 | assert.Equal(t, "13", 29 | fixesIssueMatcher.FindAllStringSubmatch("Closed #13", -1)[0][1], 30 | "should match 'close' pattern too 'Closed #13'") 31 | 32 | assert.Equal(t, "13", 33 | fixesIssueMatcher.FindAllStringSubmatch("rEsoLvEd #13", -1)[0][1], 34 | "should match mixedcase pattern") 35 | } 36 | -------------------------------------------------------------------------------- /labeler/labeler.go: -------------------------------------------------------------------------------- 1 | // SOMEWHAT DEPRECATED: labeler provides actions which add labels to issues based on criteria. It also has helper functions for labels. 2 | // This package needs work. 3 | package labeler 4 | 5 | import ( 6 | "encoding/json" 7 | "fmt" 8 | "log" 9 | "net/http" 10 | 11 | "github.com/google/go-github/github" 12 | "github.com/parkr/auto-reply/ctx" 13 | ) 14 | 15 | type PushHandler func(context *ctx.Context, event github.PushEvent) error 16 | type PullRequestHandler func(context *ctx.Context, event github.PullRequestEvent) error 17 | 18 | type LabelerHandler struct { 19 | context *ctx.Context 20 | pushHandlers []PushHandler 21 | pullRequestHandlers []PullRequestHandler 22 | } 23 | 24 | // NewHandler returns an HTTP handler which deprecates repositories 25 | // by closing new issues with a comment directing attention elsewhere. 26 | func NewHandler(context *ctx.Context, pushHandlers []PushHandler, pullRequestHandlers []PullRequestHandler) *LabelerHandler { 27 | return &LabelerHandler{ 28 | context: context, 29 | pushHandlers: pushHandlers, 30 | pullRequestHandlers: pullRequestHandlers, 31 | } 32 | } 33 | 34 | func (h *LabelerHandler) HandlePayload(w http.ResponseWriter, r *http.Request, payload []byte) { 35 | eventType := r.Header.Get("X-GitHub-Event") 36 | 37 | switch eventType { 38 | case "pull_request": 39 | h.context.IncrStat("labeler.pull_request", nil) 40 | var event github.PullRequestEvent 41 | err := json.Unmarshal(payload, &event) 42 | if err != nil { 43 | log.Println("error unmarshalling pull request event:", err) 44 | http.Error(w, "bad json", 400) 45 | return 46 | } 47 | for _, handler := range h.pullRequestHandlers { 48 | go handler(h.context, event) 49 | } 50 | fmt.Fprintf(w, "fired %d handlers", len(h.pullRequestHandlers)) 51 | 52 | case "push": 53 | h.context.IncrStat("labeler.push", nil) 54 | var event github.PushEvent 55 | err := json.Unmarshal(payload, &event) 56 | if err != nil { 57 | log.Println("error unmarshalling push event:", err) 58 | http.Error(w, "bad json", 400) 59 | return 60 | } 61 | for _, handler := range h.pushHandlers { 62 | go handler(h.context, event) 63 | } 64 | fmt.Fprintf(w, "fired %d handlers", len(h.pushHandlers)) 65 | 66 | default: 67 | h.context.IncrStat("labeler.invalid", nil) 68 | log.Printf("labeler supports pull_request and push events, not: %s", eventType) 69 | http.Error(w, "not a pull_request or push event.", 200) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /labeler/pending_rebase.go: -------------------------------------------------------------------------------- 1 | package labeler 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "time" 7 | 8 | "github.com/google/go-github/github" 9 | "github.com/parkr/auto-reply/common" 10 | "github.com/parkr/auto-reply/ctx" 11 | ) 12 | 13 | const repoMergeabilityCheckWaitSec = 2 14 | 15 | var PendingRebaseNeedsWorkPRUnlabeler = func(context *ctx.Context, payload interface{}) error { 16 | event, ok := payload.(*github.PullRequestEvent) 17 | if !ok { 18 | return context.NewError("PendingRebaseUnlabeler: not a pull request event") 19 | } 20 | 21 | if *event.Action != "synchronize" { 22 | return nil 23 | } 24 | 25 | owner, repo, num := *event.Repo.Owner.Login, *event.Repo.Name, *event.Number 26 | 27 | // Allow the job to run which determines mergeability. 28 | log.Printf("checking the mergeability of %s/%s#%d in %d sec...", owner, repo, num, repoMergeabilityCheckWaitSec) 29 | time.Sleep(repoMergeabilityCheckWaitSec * time.Second) 30 | 31 | var err error 32 | if isMergeable(context, owner, repo, num) { 33 | err = RemoveLabelIfExists(context, owner, repo, num, "pending-rebase") 34 | if err != nil { 35 | log.Printf("error removing the pending-rebase label: %v", err) 36 | } 37 | err = RemoveLabelIfExists(context, owner, repo, num, "needs-work") 38 | } else { 39 | err = fmt.Errorf("%s/%s#%d is not mergeable", owner, repo, num) 40 | } 41 | 42 | if err != nil { 43 | log.Printf("error removing the pending-rebase & needs-work labels: %v", err) 44 | } 45 | return err 46 | } 47 | 48 | func isMergeable(context *ctx.Context, owner, repo string, number int) bool { 49 | if context == nil { 50 | panic("context cannot be nil!") 51 | } 52 | 53 | pr, res, err := context.GitHub.PullRequests.Get(context.Context(), owner, repo, number) 54 | err = common.ErrorFromResponse(res, err) 55 | if err != nil { 56 | log.Printf("couldn't determine mergeability of %s/%s#%d: %v", owner, repo, number, err) 57 | return false 58 | } 59 | 60 | if pr == nil { 61 | log.Printf("isMergeable: %s/%s#%d appears not to exist", owner, repo, number) 62 | return false 63 | } 64 | 65 | if pr.Mergeable == nil { 66 | log.Printf("isMergeable: %s/%s#%d mergability is not populated in the API response", owner, repo, number) 67 | return false 68 | } 69 | 70 | return *pr.Mergeable 71 | } 72 | -------------------------------------------------------------------------------- /lgtm/lgtm.go: -------------------------------------------------------------------------------- 1 | // lgtm contains the functionality to handle approval from maintainers. 2 | package lgtm 3 | 4 | import ( 5 | "fmt" 6 | "regexp" 7 | 8 | "github.com/google/go-github/github" 9 | "github.com/parkr/auto-reply/auth" 10 | "github.com/parkr/auto-reply/ctx" 11 | ) 12 | 13 | var lgtmBodyRegexp = regexp.MustCompile(`(?i:\ALGTM[!.,]\s+|\s+LGTM[.!,]*\z|\ALGTM[.!,]*\z)`) 14 | 15 | type prRef struct { 16 | Repo Repo 17 | Number int 18 | } 19 | 20 | func (r prRef) String() string { 21 | return fmt.Sprintf("%s/%s#%d", r.Repo.Owner, r.Repo.Name, r.Number) 22 | } 23 | 24 | type Repo struct { 25 | Owner, Name string 26 | // The number of LGTM's a PR must get before going state: "success" 27 | Quorum int 28 | } 29 | 30 | type Handler struct { 31 | repos []Repo 32 | } 33 | 34 | func (h *Handler) AddRepo(owner, name string, quorum int) { 35 | if repo := h.findRepo(owner, name); repo != nil { 36 | repo.Quorum = quorum 37 | } else { 38 | h.repos = append(h.repos, Repo{ 39 | Owner: owner, 40 | Name: name, 41 | Quorum: quorum, 42 | }) 43 | } 44 | } 45 | 46 | func (h *Handler) findRepo(owner, name string) *Repo { 47 | for _, repo := range h.repos { 48 | if repo.Owner == owner && repo.Name == name { 49 | return &repo 50 | } 51 | } 52 | 53 | return nil 54 | } 55 | 56 | func (h *Handler) isEnabledFor(owner, name string) bool { 57 | return h.findRepo(owner, name) != nil 58 | } 59 | 60 | func (h *Handler) newPRRef(owner, name string, number int) prRef { 61 | repo := h.findRepo(owner, name) 62 | if repo != nil { 63 | return prRef{ 64 | Repo: *repo, 65 | Number: number, 66 | } 67 | } 68 | return prRef{ 69 | Repo: Repo{Owner: owner, Name: name, Quorum: 0}, 70 | Number: number, 71 | } 72 | } 73 | 74 | func (h *Handler) IssueCommentHandler(context *ctx.Context, payload interface{}) error { 75 | comment, ok := payload.(*github.IssueCommentEvent) 76 | if !ok { 77 | return context.NewError("lgtm.IssueCommentHandler: not an issue comment event") 78 | } 79 | 80 | // LGTM comment? 81 | if !lgtmBodyRegexp.MatchString(*comment.Comment.Body) { 82 | return context.NewError("lgtm.IssueCommentHandler: not a LGTM comment") 83 | } 84 | 85 | // Is this a pull request? 86 | if comment.Issue == nil || comment.Issue.PullRequestLinks == nil { 87 | return context.NewError("lgtm.IssueCommentHandler: not a pull request") 88 | } 89 | 90 | ref := h.newPRRef(*comment.Repo.Owner.Login, *comment.Repo.Name, *comment.Issue.Number) 91 | lgtmer := *comment.Comment.User.Login 92 | 93 | if !h.isEnabledFor(ref.Repo.Owner, ref.Repo.Name) { 94 | return context.NewError("lgtm.IssueCommentHandler: not enabled for %s/%s", ref.Repo.Owner, ref.Repo.Name) 95 | } 96 | 97 | // Does the user have merge/label abilities? 98 | if !auth.CommenterHasPushAccess(context, *comment) { 99 | return context.NewError( 100 | "%s isn't authenticated to merge anything on %s/%s", 101 | *comment.Comment.User.Login, ref.Repo.Owner, ref.Repo.Name) 102 | } 103 | 104 | // Get status 105 | info, err := getStatus(context, ref) 106 | if err != nil { 107 | return context.NewError("lgtm.IssueCommentHandler: couldn't get status for %s: %v", ref, err) 108 | } 109 | 110 | // Already LGTM'd by you? Exit. 111 | if info.IsLGTMer(lgtmer) { 112 | return context.NewError( 113 | "lgtm.IssueCommentHandler: no duplicate LGTM allowed for @%s on %s", lgtmer, ref) 114 | } 115 | 116 | info.lgtmers = append(info.lgtmers, "@"+lgtmer) 117 | if err := setStatus(context, ref, info.sha, info); err != nil { 118 | return context.NewError( 119 | "lgtm.IssueCommentHandler: had trouble adding lgtmer '%s' on %s: %v", 120 | lgtmer, ref, err) 121 | } 122 | return nil 123 | } 124 | 125 | func (h *Handler) PullRequestHandler(context *ctx.Context, payload interface{}) error { 126 | event, ok := payload.(*github.PullRequestEvent) 127 | if !ok { 128 | return context.NewError("lgtm.PullRequestHandler: not a pull request event") 129 | } 130 | 131 | ref := h.newPRRef(*event.Repo.Owner.Login, *event.Repo.Name, *event.Number) 132 | 133 | if !h.isEnabledFor(ref.Repo.Owner, ref.Repo.Name) { 134 | return context.NewError("lgtm.PullRequestHandler: not enabled for %s", ref) 135 | } 136 | 137 | if *event.Action == "opened" || *event.Action == "synchronize" { 138 | err := setStatus(context, ref, *event.PullRequest.Head.SHA, &statusInfo{ 139 | lgtmers: []string{}, 140 | quorum: ref.Repo.Quorum, 141 | sha: *event.PullRequest.Head.SHA, 142 | }) 143 | if err != nil { 144 | return context.NewError( 145 | "lgtm.PullRequestHandler: could not create status on %s: %v", 146 | ref, err, 147 | ) 148 | } 149 | } 150 | 151 | return nil 152 | } 153 | 154 | func (h *Handler) PullRequestReviewHandler(context *ctx.Context, payload interface{}) error { 155 | return context.NewError("lgtm.PullRequestReviewHandler: pull request review webhooks aren't implemented yet") 156 | 157 | //event, ok := payload.(*github.PullRequestReviewEvent) 158 | //if !ok { 159 | // return context.NewError("lgtm.PullRequestReviewHandler: not a pull request review event") 160 | //} 161 | 162 | //ref := h.newPRRef(*event.Repo.Owner.Login, *event.Repo.Name, *event.Number) 163 | 164 | //if !h.isEnabledFor(ref.Repo.Owner, ref.Repo.Name) { 165 | // return context.NewError("lgtm.PullRequestReviewHandler: not enabled for %s", ref) 166 | //} 167 | } 168 | -------------------------------------------------------------------------------- /lgtm/lgtm_test.go: -------------------------------------------------------------------------------- 1 | package lgtm 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestLGTMBodyRegexp(t *testing.T) { 8 | cases := map[string]bool{ 9 | "lgtm": true, 10 | "LGTM": true, 11 | "LGTM.": true, 12 | "LGTM!": true, 13 | "LGTM!!!": true, 14 | "@jekyllbot: LGTM": true, 15 | "LGTM, thank you.": true, 16 | "Yeah, this LGTM.": true, 17 | "Then I'll LGTM it.": false, 18 | "I'd love to get a LGTM for this.": false, 19 | "@envygeeks, can you give this a LGTM?": false, 20 | } 21 | for input, expected := range cases { 22 | if actual := lgtmBodyRegexp.MatchString(input); actual != expected { 23 | t.Fatalf("lgtmBodyRegexp expected '%v' but got '%v' for `%s`", expected, actual, input) 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /lgtm/server_test.go: -------------------------------------------------------------------------------- 1 | package lgtm 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "net/http/httptest" 7 | "net/url" 8 | "os" 9 | "testing" 10 | 11 | "github.com/google/go-github/github" 12 | ) 13 | 14 | var ( 15 | // mux is the HTTP request multiplexer used with the test server. 16 | mux *http.ServeMux 17 | 18 | // client is the GitHub client being tested. 19 | client *github.Client 20 | 21 | // server is a test HTTP server used to provide mock API responses. 22 | server *httptest.Server 23 | 24 | baseURLPath = "/api-v3" 25 | ) 26 | 27 | // setup sets up a test HTTP server along with a github.Client that is 28 | // configured to talk to that test server. Tests should register handlers on 29 | // mux which provide mock responses for the API method being tested. 30 | func setup() { 31 | // test server 32 | mux = http.NewServeMux() 33 | 34 | // We want to ensure that tests catch mistakes where the endpoint URL is 35 | // specified as absolute rather than relative. It only makes a difference 36 | // when there's a non-empty base URL path. So, use that. See issue #752. 37 | apiHandler := http.NewServeMux() 38 | apiHandler.Handle(baseURLPath+"/", http.StripPrefix(baseURLPath, mux)) 39 | apiHandler.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) { 40 | fmt.Fprintln(os.Stderr, "FAIL: Client.BaseURL path prefix is not preserved in the request URL:") 41 | fmt.Fprintln(os.Stderr) 42 | fmt.Fprintln(os.Stderr, "\t"+req.URL.String()) 43 | fmt.Fprintln(os.Stderr) 44 | fmt.Fprintln(os.Stderr, "\tDid you accidentally use an absolute endpoint URL rather than relative?") 45 | fmt.Fprintln(os.Stderr, "\tSee https://github.com/google/go-github/issues/752 for information.") 46 | http.Error(w, "Client.BaseURL path prefix is not preserved in the request URL.", http.StatusInternalServerError) 47 | }) 48 | 49 | server = httptest.NewServer(apiHandler) 50 | 51 | // github client configured to use test server 52 | client = github.NewClient(nil) 53 | url, _ := url.Parse(server.URL + baseURLPath + "/") 54 | client.BaseURL = url 55 | client.UploadURL = url 56 | } 57 | 58 | // teardown closes the test HTTP server. 59 | func teardown() { 60 | server.Close() 61 | } 62 | 63 | func testMethod(t *testing.T, r *http.Request, want string) { 64 | if got := r.Method; got != want { 65 | t.Errorf("Request method: %v, want %v", got, want) 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /lgtm/status_cache.go: -------------------------------------------------------------------------------- 1 | package lgtm 2 | 3 | import ( 4 | "fmt" 5 | "sync" 6 | 7 | "github.com/google/go-github/github" 8 | "github.com/parkr/auto-reply/ctx" 9 | ) 10 | 11 | var statusCache = statusMap{data: make(map[string]*statusInfo)} 12 | 13 | type statusMap struct { 14 | sync.Mutex // protects 'data' 15 | data map[string]*statusInfo 16 | } 17 | 18 | func lgtmContext(owner string) string { 19 | return owner + "/lgtm" 20 | } 21 | 22 | func setStatus(context *ctx.Context, ref prRef, sha string, status *statusInfo) error { 23 | _, _, err := context.GitHub.Repositories.CreateStatus( 24 | context.Context(), ref.Repo.Owner, ref.Repo.Name, sha, status.NewRepoStatus(ref.Repo.Owner)) 25 | if err != nil { 26 | return err 27 | } 28 | 29 | statusCache.Lock() 30 | statusCache.data[ref.String()] = status 31 | statusCache.Unlock() 32 | 33 | return nil 34 | } 35 | 36 | func getStatus(context *ctx.Context, ref prRef) (*statusInfo, error) { 37 | statusCache.Lock() 38 | cachedStatus, ok := statusCache.data[ref.String()] 39 | statusCache.Unlock() 40 | if ok && cachedStatus != nil { 41 | return cachedStatus, nil 42 | } 43 | 44 | pr, _, err := context.GitHub.PullRequests.Get(context.Context(), ref.Repo.Owner, ref.Repo.Name, ref.Number) 45 | if err != nil { 46 | return nil, err 47 | } 48 | 49 | statuses, _, err := context.GitHub.Repositories.ListStatuses(context.Context(), ref.Repo.Owner, ref.Repo.Name, *pr.Head.SHA, nil) 50 | if err != nil { 51 | return nil, err 52 | } 53 | 54 | var preExistingStatus *github.RepoStatus 55 | var info *statusInfo 56 | // Find the status matching context. 57 | neededContext := lgtmContext(ref.Repo.Owner) 58 | for _, status := range statuses { 59 | if *status.Context == neededContext { 60 | preExistingStatus = status 61 | info = parseStatus(*pr.Head.SHA, status) 62 | break 63 | } 64 | } 65 | 66 | // None of the contexts matched. 67 | if preExistingStatus == nil { 68 | preExistingStatus = newEmptyStatus(ref.Repo.Owner, ref.Repo.Quorum) 69 | info = parseStatus(*pr.Head.SHA, preExistingStatus) 70 | err := setStatus(context, ref, *pr.Head.SHA, info) 71 | if err != nil { 72 | fmt.Printf("getStatus: couldn't save new empty status to %s for %s: %v\n", ref, *pr.Head.SHA, err) 73 | } 74 | } 75 | 76 | if ref.Repo.Quorum != 0 { 77 | info.quorum = ref.Repo.Quorum 78 | } 79 | 80 | statusCache.Lock() 81 | statusCache.data[ref.String()] = info 82 | statusCache.Unlock() 83 | 84 | return info, nil 85 | } 86 | 87 | func newEmptyStatus(owner string, quorum int) *github.RepoStatus { 88 | return &github.RepoStatus{ 89 | Context: github.String(lgtmContext(owner)), 90 | State: github.String("pending"), 91 | Description: github.String(statusInfo{quorum: quorum}.newDescription()), 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /lgtm/status_cache_test.go: -------------------------------------------------------------------------------- 1 | package lgtm 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | "reflect" 8 | "testing" 9 | 10 | "github.com/google/go-github/github" 11 | "github.com/parkr/auto-reply/ctx" 12 | "github.com/stretchr/testify/assert" 13 | ) 14 | 15 | var ( 16 | handler = &Handler{repos: []Repo{ 17 | { 18 | Owner: "o", 19 | Name: "r", 20 | Quorum: 1, 21 | }, 22 | }} 23 | ref = handler.newPRRef("o", "r", 273) 24 | prSHA = "deadbeef0000000deadbeef" 25 | 26 | pullRequestGET = fmt.Sprintf("/repos/%s/%s/pulls/%d", ref.Repo.Owner, ref.Repo.Name, ref.Number) 27 | statusesGET = fmt.Sprintf("/repos/%s/%s/commits/%s/statuses", ref.Repo.Owner, ref.Repo.Name, prSHA) 28 | statusesPOST = fmt.Sprintf("/repos/%s/%s/statuses/%s", ref.Repo.Owner, ref.Repo.Name, prSHA) 29 | ) 30 | 31 | func TestLgtmContext(t *testing.T) { 32 | cases := []struct { 33 | owner string 34 | expected string 35 | }{ 36 | {"deadbeef", "deadbeef/lgtm"}, 37 | {"jekyll", "jekyll/lgtm"}, 38 | } 39 | for _, test := range cases { 40 | assert.Equal(t, test.expected, lgtmContext(test.owner)) 41 | } 42 | } 43 | 44 | func TestGetStatusInCache(t *testing.T) { 45 | setup() // server & client! 46 | defer teardown() 47 | context := &ctx.Context{GitHub: client} 48 | expectedInfo := &statusInfo{ 49 | lgtmers: []string{"@parkr"}, 50 | } 51 | 52 | statusCache = statusMap{data: make(map[string]*statusInfo)} 53 | statusCache.data[ref.String()] = expectedInfo 54 | 55 | info, err := getStatus(context, ref) 56 | 57 | assert.NoError(t, err) 58 | assert.Equal(t, expectedInfo, info) 59 | } 60 | 61 | func TestGetStatusAPIPRError(t *testing.T) { 62 | setup() // server & client! 63 | defer teardown() 64 | statusCache = statusMap{data: make(map[string]*statusInfo)} 65 | context := &ctx.Context{GitHub: client} 66 | prHandled := false 67 | 68 | mux.HandleFunc(pullRequestGET, func(w http.ResponseWriter, r *http.Request) { 69 | prHandled = true 70 | testMethod(t, r, "GET") 71 | http.Error(w, "huh?", http.StatusNotFound) 72 | }) 73 | 74 | info, err := getStatus(context, ref) 75 | 76 | assert.True(t, prHandled, "the PR API endpoint should be hit") 77 | assert.Error(t, err) 78 | assert.Nil(t, info) 79 | assert.Nil(t, statusCache.data[ref.String()]) 80 | } 81 | 82 | func TestGetStatusAPIStatusesError(t *testing.T) { 83 | setup() // server & client! 84 | defer teardown() 85 | statusCache = statusMap{data: make(map[string]*statusInfo)} 86 | context := &ctx.Context{GitHub: client} 87 | prHandled := false 88 | statusesHandled := false 89 | 90 | mux.HandleFunc(pullRequestGET, func(w http.ResponseWriter, r *http.Request) { 91 | testMethod(t, r, "GET") 92 | json.NewEncoder(w).Encode(&github.PullRequest{ 93 | Number: github.Int(ref.Number), 94 | Head: &github.PullRequestBranch{ 95 | Ref: github.String("blah:hi"), 96 | SHA: github.String(prSHA), 97 | }, 98 | }) 99 | prHandled = true 100 | }) 101 | 102 | mux.HandleFunc(statusesGET, func(w http.ResponseWriter, r *http.Request) { 103 | statusesHandled = true 104 | testMethod(t, r, "GET") 105 | http.Error(w, "huh?", http.StatusNotFound) 106 | }) 107 | 108 | info, err := getStatus(context, ref) 109 | 110 | assert.True(t, prHandled, "the PR API endpoint should be hit") 111 | assert.True(t, statusesHandled, "the Statuses API endpoint should be hit") 112 | assert.Error(t, err) 113 | assert.Nil(t, info) 114 | assert.Nil(t, statusCache.data[ref.String()]) 115 | } 116 | 117 | func TestGetStatusAPIStatusesNoneMatch(t *testing.T) { 118 | setup() // server & client! 119 | defer teardown() 120 | statusCache = statusMap{data: make(map[string]*statusInfo)} 121 | context := &ctx.Context{GitHub: client} 122 | prHandled := false 123 | statusesHandled := false 124 | 125 | mux.HandleFunc(pullRequestGET, func(w http.ResponseWriter, r *http.Request) { 126 | testMethod(t, r, "GET") 127 | json.NewEncoder(w).Encode(&github.PullRequest{ 128 | Number: github.Int(1), 129 | Head: &github.PullRequestBranch{ 130 | Ref: github.String("blah:hi"), 131 | SHA: github.String(prSHA), 132 | }, 133 | }) 134 | prHandled = true 135 | }) 136 | 137 | mux.HandleFunc(statusesGET, func(w http.ResponseWriter, r *http.Request) { 138 | testMethod(t, r, "GET") 139 | json.NewEncoder(w).Encode([]github.RepoStatus{ 140 | {Context: github.String("other/lgtm")}, 141 | }) 142 | statusesHandled = true 143 | }) 144 | 145 | info, err := getStatus(context, ref) 146 | 147 | expectedStatus := &statusInfo{ 148 | lgtmers: []string{}, 149 | sha: prSHA, 150 | quorum: ref.Repo.Quorum, 151 | } 152 | expectedStatus.repoStatus = expectedStatus.NewRepoStatus(ref.Repo.Owner) 153 | 154 | assert.True(t, prHandled, "the PR API endpoint should be hit") 155 | assert.True(t, statusesHandled, "the Statuses API endpoint should be hit") 156 | assert.NoError(t, err) 157 | assert.Equal(t, expectedStatus, info) 158 | assert.Equal(t, info, statusCache.data[ref.String()]) 159 | } 160 | 161 | func TestGetStatusFromAPI(t *testing.T) { 162 | setup() // server & client! 163 | defer teardown() 164 | statusCache = statusMap{data: make(map[string]*statusInfo)} 165 | context := &ctx.Context{GitHub: client} 166 | expectedRepoStatus := &github.RepoStatus{ 167 | Context: github.String("o/lgtm"), 168 | Description: github.String("Approved by @parkr, @envygeeks, and @mattr-."), 169 | } 170 | prHandled := false 171 | statusesHandled := false 172 | 173 | mux.HandleFunc(pullRequestGET, func(w http.ResponseWriter, r *http.Request) { 174 | testMethod(t, r, "GET") 175 | json.NewEncoder(w).Encode(&github.PullRequest{ 176 | Number: github.Int(ref.Number), 177 | Head: &github.PullRequestBranch{ 178 | Ref: github.String("blah:hi"), 179 | SHA: github.String(prSHA), 180 | }, 181 | }) 182 | prHandled = true 183 | }) 184 | 185 | mux.HandleFunc(statusesGET, func(w http.ResponseWriter, r *http.Request) { 186 | testMethod(t, r, "GET") 187 | json.NewEncoder(w).Encode([]github.RepoStatus{ 188 | {Context: github.String("other/lgtm"), Description: github.String("no")}, 189 | *expectedRepoStatus, 190 | }) 191 | statusesHandled = true 192 | }) 193 | 194 | info, err := getStatus(context, ref) 195 | 196 | expectedStatus := &statusInfo{ 197 | lgtmers: []string{"@parkr", "@envygeeks", "@mattr-"}, 198 | sha: prSHA, 199 | quorum: ref.Repo.Quorum, 200 | } 201 | expectedStatus.repoStatus = expectedRepoStatus 202 | 203 | assert.True(t, prHandled, "the PR API endpoint should be hit") 204 | assert.True(t, statusesHandled, "the Statuses API endpoint should be hit") 205 | assert.NoError(t, err) 206 | assert.Equal(t, expectedStatus, info) 207 | assert.Equal(t, expectedRepoStatus, info.repoStatus) 208 | assert.Equal(t, info, statusCache.data[ref.String()]) 209 | } 210 | 211 | func TestSetStatus(t *testing.T) { 212 | setup() // server & client! 213 | defer teardown() 214 | context := &ctx.Context{GitHub: client} 215 | 216 | statusCache = statusMap{data: make(map[string]*statusInfo)} 217 | 218 | statusesHandled := false 219 | newStatus := &statusInfo{ 220 | lgtmers: []string{}, 221 | sha: prSHA, 222 | quorum: ref.Repo.Quorum, 223 | } 224 | input := newStatus.NewRepoStatus("o") 225 | 226 | mux.HandleFunc(statusesPOST, func(w http.ResponseWriter, r *http.Request) { 227 | statusesHandled = true 228 | testMethod(t, r, "POST") 229 | 230 | v := new(github.RepoStatus) 231 | json.NewDecoder(r.Body).Decode(v) 232 | 233 | if !reflect.DeepEqual(v, input) { 234 | t.Errorf("Request body = %+v, want %+v", v, input) 235 | } 236 | fmt.Fprint(w, `{"id":1}`) 237 | }) 238 | 239 | assert.NoError(t, setStatus( 240 | context, 241 | ref, 242 | prSHA, 243 | newStatus, 244 | )) 245 | assert.True(t, statusesHandled, "the Statuses API endpoint should be hit") 246 | assert.Equal(t, newStatus, statusCache.data[ref.String()]) 247 | } 248 | 249 | func TestSetStatusHTTPError(t *testing.T) { 250 | setup() // server & client! 251 | defer teardown() 252 | context := &ctx.Context{GitHub: client} 253 | 254 | statusCache = statusMap{data: make(map[string]*statusInfo)} 255 | 256 | statusesHandled := false 257 | newStatus := &statusInfo{ 258 | lgtmers: []string{}, 259 | sha: prSHA, 260 | quorum: ref.Repo.Quorum, 261 | } 262 | 263 | mux.HandleFunc(statusesPOST, func(w http.ResponseWriter, r *http.Request) { 264 | statusesHandled = true 265 | testMethod(t, r, "POST") 266 | http.Error(w, "No way, Jose!", http.StatusForbidden) 267 | }) 268 | 269 | assert.Error(t, setStatus( 270 | context, 271 | ref, 272 | prSHA, 273 | newStatus, 274 | )) 275 | assert.True(t, statusesHandled, "the Statuses API endpoint should be hit") 276 | assert.Nil(t, statusCache.data[ref.String()]) 277 | } 278 | 279 | func TestNewEmptyStatus(t *testing.T) { 280 | cases := []struct { 281 | owner string 282 | expContext string 283 | }{ 284 | {"deadbeef", "deadbeef/lgtm"}, 285 | {"jekyll", "jekyll/lgtm"}, 286 | } 287 | for _, test := range cases { 288 | status := newEmptyStatus(test.owner, 1) 289 | assert.Equal(t, test.expContext, *status.Context) 290 | assert.Equal(t, "pending", *status.State) 291 | assert.Equal(t, "Awaiting approval from at least 1 maintainer.", *status.Description) 292 | } 293 | } 294 | -------------------------------------------------------------------------------- /lgtm/status_info.go: -------------------------------------------------------------------------------- 1 | package lgtm 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | "strconv" 7 | "strings" 8 | 9 | "github.com/google/go-github/github" 10 | ) 11 | 12 | var lgtmerExtractor = regexp.MustCompile("@[a-zA-Z0-9_-]+") 13 | var remainingLGTMsExtractor = regexp.MustCompile(`Waiting for approval from at least (\d+)|Requires (\d+) more LGTM('s)?`) 14 | 15 | type statusInfo struct { 16 | lgtmers []string 17 | quorum int 18 | sha string 19 | repoStatus *github.RepoStatus 20 | } 21 | 22 | func parseStatus(sha string, repoStatus *github.RepoStatus) *statusInfo { 23 | status := &statusInfo{sha: sha, repoStatus: repoStatus, lgtmers: []string{}} 24 | 25 | if repoStatus.Description != nil { 26 | // Extract LGTMers. 27 | lgtmersExtracted := lgtmerExtractor.FindAllStringSubmatch(*repoStatus.Description, -1) 28 | if len(lgtmersExtracted) > 0 { 29 | for _, lgtmerWrapping := range lgtmersExtracted { 30 | for _, lgtmer := range lgtmerWrapping { 31 | status.lgtmers = append(status.lgtmers, lgtmer) 32 | } 33 | } 34 | } 35 | 36 | status.quorum = len(status.lgtmers) 37 | 38 | // Extract additional quorum. :) 39 | extractedRemainingLGTMs := remainingLGTMsExtractor.FindAllStringSubmatch(*repoStatus.Description, -1) 40 | if len(extractedRemainingLGTMs) > 0 && len(extractedRemainingLGTMs[0]) > 2 { 41 | remainingLGTMsString := extractedRemainingLGTMs[0][1] 42 | if remainingLGTMsString == "" { 43 | remainingLGTMsString = extractedRemainingLGTMs[0][2] 44 | } 45 | 46 | remainingLGTMs, err := strconv.Atoi(remainingLGTMsString) 47 | if err != nil { 48 | fmt.Printf("lgtm.parseStatus: error parsing %q for remaining LGTM's: %v", *repoStatus.Description, err) 49 | } 50 | status.quorum += remainingLGTMs 51 | } 52 | } 53 | 54 | return status 55 | } 56 | 57 | func (s statusInfo) IsLGTMer(username string) bool { 58 | lowerUsername := strings.ToLower(username) 59 | for _, lgtmer := range s.lgtmers { 60 | lowerLgtmer := strings.ToLower(lgtmer) 61 | if lowerLgtmer == lowerUsername || lowerLgtmer == "@"+lowerUsername { 62 | return true 63 | } 64 | } 65 | return false 66 | } 67 | 68 | func (s statusInfo) newState() string { 69 | if len(s.lgtmers) >= s.quorum { 70 | return "success" 71 | } 72 | return "pending" 73 | } 74 | 75 | // newDescription produces the LGTM status description based on the LGTMers 76 | // and quorum values specified for this statusInfo. 77 | func (s statusInfo) newDescription() string { 78 | if s.quorum == 0 { 79 | return "No approval is required." 80 | } 81 | 82 | if len(s.lgtmers) == 0 { 83 | message := fmt.Sprintf("Awaiting approval from at least %d maintainer", s.quorum) 84 | if s.quorum > 1 { 85 | message += "s" 86 | } 87 | return message + "." 88 | } 89 | 90 | if requiredLGTMsDesc := s.newLGTMsRequiredDescription(); requiredLGTMsDesc != "" { 91 | return s.newApprovedByDescription() + " " + requiredLGTMsDesc 92 | } else { 93 | return s.newApprovedByDescription() 94 | } 95 | } 96 | 97 | func (s statusInfo) newLGTMsRequiredDescription() string { 98 | remaining := s.quorum - len(s.lgtmers) 99 | 100 | switch { 101 | case remaining <= 0: 102 | return "" 103 | case remaining == 1: 104 | return "Requires 1 more LGTM." 105 | default: 106 | return fmt.Sprintf("Requires %d more LGTM's.", remaining) 107 | } 108 | } 109 | 110 | func (s statusInfo) newApprovedByDescription() string { 111 | switch len(s.lgtmers) { 112 | case 0: 113 | return "Not yet approved by any maintainers." 114 | case 1: 115 | return fmt.Sprintf("Approved by %s.", s.lgtmers[0]) 116 | case 2: 117 | return fmt.Sprintf("Approved by %s and %s.", s.lgtmers[0], s.lgtmers[1]) 118 | default: 119 | lastIndex := len(s.lgtmers) - 1 120 | return fmt.Sprintf("Approved by %s, and %s.", 121 | strings.Join(s.lgtmers[0:lastIndex], ", "), s.lgtmers[lastIndex]) 122 | } 123 | } 124 | 125 | func (s statusInfo) NewRepoStatus(owner string) *github.RepoStatus { 126 | return &github.RepoStatus{ 127 | Context: github.String(lgtmContext(owner)), 128 | State: github.String(s.newState()), 129 | Description: github.String(s.newDescription()), 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /lgtm/status_info_test.go: -------------------------------------------------------------------------------- 1 | package lgtm 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/google/go-github/github" 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestParseStatus(t *testing.T) { 12 | cases := []struct { 13 | sha string 14 | description string 15 | expectedLgtmers []string 16 | expectedQuorum int 17 | }{ 18 | {"deadbeef", "", []string{}, 0}, 19 | {"deadbeef", "Waiting for approval from at least 2 maintainers.", []string{}, 2}, 20 | {"deadbeef", "Waiting for approval from at least 22 maintainers.", []string{}, 22}, 21 | {"deadbeef", "Approved by @parkr. Requires 1 more LGTM.", []string{"@parkr"}, 2}, 22 | {"deadbeef", "@parkr have approved this PR. Requires 32 more LGTM's.", []string{"@parkr"}, 33}, 23 | {"deadbeef", "@parkr, and @envygeeks have approved this PR.", []string{"@parkr", "@envygeeks"}, 2}, 24 | {"deadbeef", "@mattr-, @parkr, and @BenBalter have approved this PR. Requires no more LGTM's.", []string{"@mattr-", "@parkr", "@BenBalter"}, 3}, 25 | } 26 | for _, test := range cases { 27 | parsed := parseStatus(test.sha, &github.RepoStatus{Description: github.String(test.description)}) 28 | assert.Equal(t, 29 | test.expectedLgtmers, parsed.lgtmers, 30 | fmt.Sprintf("parsing description: %q", test.description)) 31 | assert.Equal(t, 32 | test.expectedQuorum, parsed.quorum, 33 | fmt.Sprintf("parsing description: %q", test.description)) 34 | assert.Equal(t, test.sha, parsed.sha) 35 | } 36 | } 37 | 38 | func TestStatusInfoIsLGTMer(t *testing.T) { 39 | cases := []struct { 40 | info statusInfo 41 | lgtmerInQuestion string 42 | islgtmer bool 43 | }{ 44 | {statusInfo{}, "@parkr", false}, 45 | {statusInfo{lgtmers: []string{"@parkr"}}, "@parkr", true}, 46 | {statusInfo{lgtmers: []string{"@parkr"}}, "@mattr-", false}, 47 | {statusInfo{lgtmers: []string{"@parkr", "@mattr-"}}, "@mattr-", true}, 48 | {statusInfo{lgtmers: []string{"@parkr", "@mattr-"}}, "@parkr-", false}, 49 | {statusInfo{lgtmers: []string{"@parkr", "@mattr-"}}, "@parkr", true}, 50 | {statusInfo{lgtmers: []string{"@parkr", "@mattr-"}}, "@PARKR", true}, 51 | {statusInfo{lgtmers: []string{"@benBalter", "@mattr-"}}, "@benbalter", true}, 52 | } 53 | for _, test := range cases { 54 | assert.Equal(t, 55 | test.islgtmer, test.info.IsLGTMer(test.lgtmerInQuestion), 56 | fmt.Sprintf("asking about: %q for lgtmers: %q", test.lgtmerInQuestion, test.info.lgtmers)) 57 | } 58 | } 59 | 60 | func TestNewState(t *testing.T) { 61 | cases := []struct { 62 | lgtmers []string 63 | quorum int 64 | expected string 65 | }{ 66 | {[]string{}, 0, "success"}, 67 | {[]string{}, 1, "pending"}, 68 | {[]string{}, 2, "pending"}, 69 | {[]string{"@parkr"}, 0, "success"}, 70 | {[]string{"@parkr"}, 1, "success"}, 71 | {[]string{"@parkr"}, 2, "pending"}, 72 | {[]string{"@parkr", "@mattr-"}, 0, "success"}, 73 | {[]string{"@parkr", "@mattr-"}, 1, "success"}, 74 | {[]string{"@parkr", "@mattr-"}, 2, "success"}, 75 | } 76 | for _, test := range cases { 77 | info := statusInfo{lgtmers: test.lgtmers, quorum: test.quorum} 78 | assert.Equal(t, 79 | test.expected, info.newState(), 80 | fmt.Sprintf("with lgtmers: %q and quorum: %d", test.lgtmers, test.quorum)) 81 | } 82 | } 83 | 84 | func TestNewDescription(t *testing.T) { 85 | cases := []struct { 86 | lgtmers []string 87 | quorum int 88 | description string 89 | }{ 90 | {nil, 0, "No approval is required."}, 91 | {nil, 1, "Awaiting approval from at least 1 maintainer."}, 92 | {[]string{}, 2, "Awaiting approval from at least 2 maintainers."}, 93 | {[]string{"@parkr"}, 2, "Approved by @parkr. Requires 1 more LGTM."}, 94 | {[]string{"@parkr", "@envygeeks"}, 2, "Approved by @parkr and @envygeeks."}, 95 | {[]string{"@mattr-", "@envygeeks", "@parkr"}, 5, "Approved by @mattr-, @envygeeks, and @parkr. Requires 2 more LGTM's."}, 96 | } 97 | for _, test := range cases { 98 | info := statusInfo{lgtmers: test.lgtmers, quorum: test.quorum} 99 | actual := info.newDescription() 100 | assert.Equal(t, test.description, actual) 101 | assert.True(t, len(actual) <= 140, fmt.Sprintf("%q must be <= 140 chars.", actual)) 102 | } 103 | } 104 | 105 | func TestLGTMsRequiredDescription(t *testing.T) { 106 | cases := []struct { 107 | lgtmers []string 108 | quorum int 109 | expected string 110 | }{ 111 | {nil, 0, ""}, 112 | {nil, 1, "Requires 1 more LGTM."}, 113 | {[]string{}, 2, "Requires 2 more LGTM's."}, 114 | {[]string{"@parkr"}, 2, "Requires 1 more LGTM."}, 115 | {[]string{"@parkr", "@envygeeks"}, 2, ""}, 116 | {[]string{"@mattr-", "@envygeeks", "@parkr"}, 5, "Requires 2 more LGTM's."}, 117 | } 118 | for _, test := range cases { 119 | info := statusInfo{lgtmers: test.lgtmers, quorum: test.quorum} 120 | actual := info.newLGTMsRequiredDescription() 121 | assert.Equal(t, test.expected, actual) 122 | assert.True(t, len(actual) <= 140, fmt.Sprintf("%q must be <= 140 chars.", actual)) 123 | } 124 | } 125 | 126 | func TestNewApprovedByDescription(t *testing.T) { 127 | } 128 | 129 | func TestStatusInfoNewRepoStatus(t *testing.T) { 130 | cases := []struct { 131 | owner string 132 | lgtmers []string 133 | quorum int 134 | expContext string 135 | expState string 136 | expDescription string 137 | }{ 138 | {"octocat", []string{}, 0, "octocat/lgtm", "success", "No approval is required."}, 139 | {"parkr", []string{}, 0, "parkr/lgtm", "success", "No approval is required."}, 140 | {"jekyll", []string{}, 1, "jekyll/lgtm", "pending", "Awaiting approval from at least 1 maintainer."}, 141 | {"jekyll", []string{"@parkr"}, 1, "jekyll/lgtm", "success", "Approved by @parkr."}, 142 | {"jekyll", []string{"@parkr"}, 2, "jekyll/lgtm", "pending", "Approved by @parkr. Requires 1 more LGTM."}, 143 | {"jekyll", []string{"@parkr", "@envygeeks"}, 1, "jekyll/lgtm", "success", "Approved by @parkr and @envygeeks."}, 144 | {"jekyll", []string{"@parkr", "@envygeeks"}, 2, "jekyll/lgtm", "success", "Approved by @parkr and @envygeeks."}, 145 | {"jekyll", []string{"@parkr", "@mattr-", "@envygeeks"}, 6, "jekyll/lgtm", "pending", "Approved by @parkr, @mattr-, and @envygeeks. Requires 3 more LGTM's."}, 146 | } 147 | for _, test := range cases { 148 | status := statusInfo{lgtmers: test.lgtmers, quorum: test.quorum} 149 | newStatus := status.NewRepoStatus(test.owner) 150 | assert.Equal(t, 151 | test.expContext, *newStatus.Context, 152 | fmt.Sprintf("with lgtmers: %q and quorum: %d", test.lgtmers, test.quorum)) 153 | assert.Equal(t, 154 | test.expState, *newStatus.State, 155 | fmt.Sprintf("with lgtmers: %q and quorum: %d", test.lgtmers, test.quorum)) 156 | assert.Equal(t, 157 | test.expDescription, *newStatus.Description, 158 | fmt.Sprintf("with lgtmers: %q and quorum: %d", test.lgtmers, test.quorum)) 159 | assert.True(t, len(*newStatus.Description) <= 140, fmt.Sprintf("%q must be <= 140 chars.", *newStatus.Description)) 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /releases/releases.go: -------------------------------------------------------------------------------- 1 | package releases 2 | 3 | import ( 4 | "fmt" 5 | "sort" 6 | 7 | "github.com/google/go-github/github" 8 | "github.com/hashicorp/go-version" 9 | "github.com/parkr/auto-reply/ctx" 10 | "github.com/parkr/auto-reply/jekyll" 11 | ) 12 | 13 | func LatestRelease(context *ctx.Context, repo jekyll.Repository) (*github.RepositoryRelease, error) { 14 | releases, _, err := context.GitHub.Repositories.ListReleases(context.Context(), repo.Owner(), repo.Name(), &github.ListOptions{PerPage: 300}) 15 | if err != nil { 16 | return nil, err 17 | } 18 | 19 | if len(releases) == 0 { 20 | return nil, nil 21 | } 22 | 23 | versionMap := map[string]*github.RepositoryRelease{} 24 | versions := []*version.Version{} 25 | for _, release := range releases { 26 | v, err := version.NewVersion(release.GetTagName()) 27 | if err != nil { 28 | continue 29 | } 30 | versionMap[v.String()] = release 31 | versions = append(versions, v) 32 | } 33 | 34 | // After this, the versions are properly sorted 35 | sort.Sort(sort.Reverse(version.Collection(versions))) 36 | 37 | if latestRelease, ok := versionMap[versions[0].String()]; ok { 38 | return latestRelease, nil 39 | } 40 | 41 | return nil, fmt.Errorf("%s: couldn't find %s in versions %+v", repo, versions[0], versions) 42 | } 43 | 44 | func CommitsSinceRelease(context *ctx.Context, repo jekyll.Repository, latestRelease *github.RepositoryRelease) (int, error) { 45 | comparison, _, err := context.GitHub.Repositories.CompareCommits( 46 | context.Context(), 47 | repo.Owner(), repo.Name(), 48 | latestRelease.GetTagName(), "master", 49 | ) 50 | if err != nil { 51 | return -1, fmt.Errorf("error fetching commit comparison for %s...master for %s: %v", latestRelease.GetTagName(), repo, err) 52 | } 53 | 54 | return comparison.GetTotalCommits(), nil 55 | } 56 | -------------------------------------------------------------------------------- /search/github.go: -------------------------------------------------------------------------------- 1 | package search 2 | 3 | import ( 4 | "github.com/google/go-github/github" 5 | "github.com/parkr/auto-reply/ctx" 6 | "github.com/parkr/githubapi/githubsearch" 7 | ) 8 | 9 | func GitHubIssues(context *ctx.Context, query githubsearch.IssueSearchParameters) ([]github.Issue, error) { 10 | issues := []github.Issue{} 11 | opts := &github.SearchOptions{Sort: "created", Order: "desc", ListOptions: github.ListOptions{Page: 0, PerPage: 100}} 12 | queryStr := query.String() 13 | for { 14 | result, resp, err := context.GitHub.Search.Issues(context.Context(), queryStr, opts) 15 | if err != nil { 16 | return nil, context.NewError("search: error running GitHub issues search query: '%s': %v", queryStr, err) 17 | } 18 | 19 | for _, issue := range result.Issues { 20 | issues = append(issues, issue) 21 | } 22 | 23 | if resp.NextPage == 0 { 24 | break 25 | } 26 | opts.ListOptions.Page = resp.NextPage 27 | } 28 | 29 | return issues, nil 30 | } 31 | -------------------------------------------------------------------------------- /sentry/sentry.go: -------------------------------------------------------------------------------- 1 | package sentry 2 | 3 | import ( 4 | "errors" 5 | "log" 6 | "net/http" 7 | "os" 8 | 9 | "github.com/getsentry/raven-go" 10 | ) 11 | 12 | var sentryEnvVarName = "SENTRY_DSN" 13 | 14 | func newRavenClient(tags map[string]string) (*raven.Client, error) { 15 | dsn := os.Getenv(sentryEnvVarName) 16 | if dsn == "" { 17 | return nil, errors.New("sentry: missing env var " + sentryEnvVarName) 18 | } 19 | return raven.NewWithTags(dsn, tags) 20 | } 21 | 22 | // 23 | // Top-level SentryClient which should be lowest-level interface. 24 | // 25 | 26 | type SentryClient interface { 27 | Recover(func() error) (err interface{}, errID string) 28 | RecoverAndExit(func() error) 29 | GetSentry() *raven.Client 30 | } 31 | 32 | func NewClient(tags map[string]string) (SentryClient, error) { 33 | ravenClient, err := newRavenClient(tags) 34 | return &sentryClient{ravenClient: ravenClient}, err 35 | } 36 | 37 | type sentryClient struct { 38 | ravenClient *raven.Client 39 | } 40 | 41 | func (c *sentryClient) Recover(f func() error) (err interface{}, errID string) { 42 | return c.ravenClient.CapturePanicAndWait(func() { 43 | if err := f(); err != nil { 44 | log.Printf("error encountered: %+v", err) 45 | panic(err) 46 | } 47 | }, nil) 48 | } 49 | 50 | func (c *sentryClient) RecoverAndExit(f func() error) { 51 | if err, _ := c.Recover(f); err != nil { 52 | log.Fatalf("panicked!") 53 | } 54 | } 55 | 56 | func (c *sentryClient) GetSentry() *raven.Client { 57 | return c.ravenClient 58 | } 59 | 60 | // 61 | // HTTP wrapper for Sentry 62 | // 63 | 64 | type sentryHTTPHandler struct { 65 | next http.Handler 66 | 67 | ravenClient SentryClient 68 | } 69 | 70 | func NewHTTPHandler(handler http.Handler, tags map[string]string) http.Handler { 71 | client, err := NewClient(tags) 72 | if err != nil { 73 | panic(err) 74 | } 75 | return &sentryHTTPHandler{ 76 | next: handler, 77 | ravenClient: client, 78 | } 79 | } 80 | 81 | func (h *sentryHTTPHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 82 | h.ravenClient.Recover(func() error { 83 | h.next.ServeHTTP(w, r) 84 | return nil 85 | }) 86 | } 87 | -------------------------------------------------------------------------------- /stale/stale.go: -------------------------------------------------------------------------------- 1 | package stale 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/google/go-github/github" 7 | "github.com/parkr/auto-reply/ctx" 8 | "github.com/parkr/auto-reply/labeler" 9 | ) 10 | 11 | var ( 12 | defaultNonStaleableLabels = []string{ 13 | "has-pull-request", 14 | "pinned", 15 | "security", 16 | } 17 | ) 18 | 19 | type Configuration struct { 20 | // Whether to actuall perform the action. If false, just outputs what *would* happen. 21 | Perform bool 22 | 23 | // Labels, in addition to "stale", which should be mark an issue as already stale. 24 | StaleLabels []string 25 | 26 | // If an issue has this label, it is not stale. 27 | ExemptLabels []string 28 | 29 | // After this duration, the next action is performed (either mark or close). 30 | DormantDuration time.Duration 31 | 32 | // Comment to leave on a stale issue if being marked. 33 | // No comment is left if this is stale. 34 | NotificationComment *github.IssueComment 35 | } 36 | 37 | func MarkAndCloseForRepo(context *ctx.Context, config Configuration) error { 38 | if context.Repo.IsEmpty() { 39 | return context.NewError("stale: no repository present in context") 40 | } 41 | 42 | owner, name, nonStaleIssues, failedIssues := context.Repo.Owner, context.Repo.Name, 0, 0 43 | 44 | staleIssuesListOptions := &github.IssueListByRepoOptions{ 45 | State: "open", 46 | Sort: "updated", 47 | Direction: "asc", 48 | ListOptions: github.ListOptions{Page: 0, PerPage: 200}, 49 | } 50 | 51 | allIssues := []*github.Issue{} 52 | 53 | for { 54 | issues, resp, err := context.GitHub.Issues.ListByRepo(context.Context(), owner, name, staleIssuesListOptions) 55 | if err != nil { 56 | return context.NewError("could not list issues for %s/%s: %v", owner, name, err) 57 | } 58 | 59 | allIssues = append(allIssues, issues...) 60 | 61 | if resp.NextPage == 0 { 62 | break 63 | } 64 | staleIssuesListOptions.ListOptions.Page = resp.NextPage 65 | } 66 | 67 | if len(allIssues) == 0 { 68 | context.Log("no issues for %s/%s", owner, name) 69 | return nil 70 | } 71 | 72 | for _, issue := range allIssues { 73 | if !IsStale(issue, config) { 74 | nonStaleIssues += 1 75 | continue 76 | } 77 | 78 | err := MarkOrCloseIssue(context, issue, config) 79 | if err != nil { 80 | context.Log("ERR %s !! failed marking or closing issue %d: %+v", context.Repo, *issue.Number, err) 81 | failedIssues += 1 82 | } 83 | } 84 | 85 | context.Log("INF %s -- ignored non-stale issues: %d", context.Repo, nonStaleIssues) 86 | if failedIssues > 0 { 87 | return context.NewError("ERR %s !! failed issues: %d", context.Repo, failedIssues) 88 | } 89 | 90 | return nil 91 | } 92 | 93 | func MarkOrCloseIssue(context *ctx.Context, issue *github.Issue, config Configuration) error { 94 | if context.Repo.IsEmpty() { 95 | return context.NewError("stale: no repository present in context") 96 | } 97 | 98 | if !IsStale(issue, config) { 99 | return context.NewError("stale: issue %s#%d is not stale", context.Repo, *issue.Number) 100 | } 101 | 102 | if hasStaleLabel(issue, config) { 103 | // Close! 104 | if config.Perform { 105 | context.Log("https://github.com/%s/issues/%d is being closed.", context.Repo, *issue.Number) 106 | return closeIssue(context, issue) 107 | } else { 108 | context.Log("https://github.com/%s/issues/%d would have been closed (dry-run).", context.Repo, *issue.Number) 109 | } 110 | } else { 111 | // Mark! 112 | if config.Perform { 113 | context.Log("https://github.com/%s/issues/%d is being marked.", context.Repo, *issue.Number) 114 | return markIssue(context, issue, config.NotificationComment) 115 | } else { 116 | context.Log("https://github.com/%s/issues/%d would have been marked (dry-run).", context.Repo, *issue.Number) 117 | } 118 | } 119 | 120 | return nil 121 | } 122 | 123 | func closeIssue(context *ctx.Context, issue *github.Issue) error { 124 | _, _, err := context.GitHub.Issues.Edit( 125 | context.Context(), 126 | context.Repo.Owner, 127 | context.Repo.Name, 128 | *issue.Number, 129 | &github.IssueRequest{State: github.String("closed")}, 130 | ) 131 | return err 132 | } 133 | 134 | func markIssue(context *ctx.Context, issue *github.Issue, comment *github.IssueComment) error { 135 | // Mark with "stale" label. 136 | err := labeler.AddLabels(context, context.Repo.Owner, context.Repo.Name, *issue.Number, []string{"stale"}) 137 | if err != nil { 138 | return context.NewError("stale: couldn't mark issue as stale %s#%d: %+v", context.Repo, *issue.Number, err) 139 | } 140 | 141 | if comment != nil { 142 | // Leave comment. 143 | _, _, err := context.GitHub.Issues.CreateComment( 144 | context.Context(), 145 | context.Repo.Owner, context.Repo.Name, *issue.Number, comment) 146 | if err != nil { 147 | return context.NewError("stale: couldn't leave comment on %s#%d: %+v", context.Repo, *issue.Number, err) 148 | } 149 | } 150 | 151 | return nil 152 | } 153 | 154 | func IsStale(issue *github.Issue, config Configuration) bool { 155 | return issue.PullRequestLinks == nil && 156 | !isUpdatedWithinDuration(issue, config) && 157 | excludesNonStaleableLabels(issue, config) 158 | } 159 | 160 | func isUpdatedWithinDuration(issue *github.Issue, config Configuration) bool { 161 | return (*issue.UpdatedAt).Unix() >= time.Now().Add(-config.DormantDuration).Unix() 162 | } 163 | 164 | // Returns true if none of the exempt labels are present, false if at least one exempt label is present. 165 | func excludesNonStaleableLabels(issue *github.Issue, config Configuration) bool { 166 | if len(issue.Labels) == 0 { 167 | return true 168 | } 169 | 170 | for _, exemptLabel := range config.ExemptLabels { 171 | for _, issueLabel := range issue.Labels { 172 | if *issueLabel.Name == exemptLabel { 173 | return false 174 | } 175 | } 176 | } 177 | 178 | return true 179 | } 180 | 181 | func hasStaleLabel(issue *github.Issue, config Configuration) bool { 182 | if issue.Labels == nil { 183 | return false 184 | } 185 | 186 | for _, issueLabel := range issue.Labels { 187 | if *issueLabel.Name == "stale" { 188 | return true 189 | } 190 | for _, staleLabel := range config.StaleLabels { 191 | if *issueLabel.Name == staleLabel { 192 | return true 193 | } 194 | } 195 | } 196 | 197 | return false 198 | } 199 | -------------------------------------------------------------------------------- /stale/stale_test.go: -------------------------------------------------------------------------------- 1 | package stale 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | "time" 7 | 8 | "github.com/google/go-github/github" 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestIsUpdatedWithinDuration(t *testing.T) { 13 | twoMonthsAgo := time.Now().AddDate(0, -2, 0) 14 | dormantDuration := time.Since(twoMonthsAgo) 15 | config := Configuration{DormantDuration: dormantDuration} 16 | 17 | cases := []struct { 18 | updatedAtDate time.Time 19 | isUpdatedWithinDurationReturnValue bool 20 | }{ 21 | {time.Now().AddDate(-1, 0, 0), false}, 22 | {time.Now().AddDate(0, -2, -1), false}, 23 | {time.Now().AddDate(0, -1, -30), true}, 24 | {time.Now().AddDate(0, -1, -29), true}, 25 | {time.Now(), true}, 26 | } 27 | 28 | for _, testCase := range cases { 29 | issue := &github.Issue{UpdatedAt: &testCase.updatedAtDate} 30 | assert.Equal(t, 31 | testCase.isUpdatedWithinDurationReturnValue, 32 | isUpdatedWithinDuration(issue, config), 33 | fmt.Sprintf( 34 | "date='%s' config.DormantDuration='%s' time.Since(date)='%s'", 35 | testCase.updatedAtDate, 36 | config.DormantDuration, 37 | time.Since(testCase.updatedAtDate)), 38 | ) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /travis/failing_fmt_build.go: -------------------------------------------------------------------------------- 1 | package travis 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "log" 7 | "net/http" 8 | "strconv" 9 | "strings" 10 | 11 | "github.com/google/go-github/github" 12 | "github.com/parkr/auto-reply/ctx" 13 | "github.com/parkr/auto-reply/search" 14 | "github.com/parkr/githubapi/githubsearch" 15 | ) 16 | 17 | var ( 18 | travisAPIBaseURL = "https://api.travis-ci.org" 19 | travisAPIContentType = "application/vnd.travis-ci.2+json" 20 | failingFmtBuildLabels = []string{"tests", "help-wanted"} 21 | ) 22 | 23 | type travisBuild struct { 24 | JobIDs []int64 `json:"job_ids"` 25 | } 26 | 27 | type travisJob struct { 28 | State string 29 | Config travisJobConfig 30 | } 31 | 32 | type travisJobConfig struct { 33 | Env string 34 | } 35 | 36 | func FailingFmtBuildHandler(context *ctx.Context, payload interface{}) error { 37 | status, ok := payload.(*github.StatusEvent) 38 | if !ok { 39 | return context.NewError("FailingFmtBuildHandler: not an status event") 40 | } 41 | 42 | if *status.State != "failure" { 43 | return context.NewError("FailingFmtBuildHandler: not a failure status event") 44 | } 45 | 46 | if *status.Context != "continuous-integration/travis-ci/push" { 47 | return context.NewError("FailingFmtBuildHandler: not a continuous-integration/travis-ci/push context") 48 | } 49 | 50 | if status.Branches != nil && len(status.Branches) > 0 && *status.Branches[0].Name != "master" { 51 | return context.NewError("FailingFmtBuildHandler: not a travis build on the master branch") 52 | } 53 | 54 | context.SetRepo(*status.Repo.Owner.Login, *status.Repo.Name) 55 | 56 | buildID, err := buildIDFromTargetURL(*status.TargetURL) 57 | if err != nil { 58 | return context.NewError("FailingFmtBuildHandler: couldn't extract build ID from %q: %+v", *status.TargetURL, err) 59 | } 60 | uri := fmt.Sprintf("/repos/%s/%s/builds/%d", context.Repo.Owner, context.Repo.Name, buildID) 61 | resp, err := httpGetTravis(uri) 62 | if err != nil { 63 | return context.NewError("FailingFmtBuildHandler: %+v", err) 64 | } 65 | 66 | build := struct { 67 | Build travisBuild `json:"build"` 68 | }{Build: travisBuild{}} 69 | err = json.NewDecoder(resp.Body).Decode(&build) 70 | if err != nil { 71 | return context.NewError("FailingFmtBuildHandler: couldn't decode build json: %+v", err) 72 | } 73 | log.Printf("FailingFmtBuildHandler: %q response: %+v %+v", uri, resp, build) 74 | 75 | for _, jobID := range build.Build.JobIDs { 76 | job := struct { 77 | Job travisJob `json:"job"` 78 | }{Job: travisJob{}} 79 | resp, err := httpGetTravis("/jobs/" + strconv.FormatInt(jobID, 10)) 80 | if err != nil { 81 | return context.NewError("FailingFmtBuildHandler: couldn't get job info from travis: %+v", err) 82 | } 83 | err = json.NewDecoder(resp.Body).Decode(&job) 84 | if err != nil { 85 | return context.NewError("FailingFmtBuildHandler: couldn't decode job json: %+v", err) 86 | } 87 | log.Printf("FailingFmtBuildHandler: job %d response: %+v %+v", jobID, resp, job) 88 | if job.Job.State == "failed" && job.Job.Config.Env == "TEST_SUITE=fmt" { 89 | // Winner! Open an issue if there isn't already one. 90 | query := githubsearch.IssueSearchParameters{ 91 | Repository: &githubsearch.RepositoryName{ 92 | Owner: context.Repo.Owner, 93 | Name: context.Repo.Name, 94 | }, 95 | State: githubsearch.Open, 96 | Scope: githubsearch.TitleScope, 97 | Query: "fmt is failing on master", 98 | } 99 | issues, err := search.GitHubIssues(context, query) 100 | if err != nil { 101 | return context.NewError("FailingFmtBuildHandler: couldn't run query %q: %+v", query, err) 102 | } 103 | if len(issues) > 0 { 104 | log.Printf("We already have an issue or issues for this failure! %s", *issues[0].HTMLURL) 105 | } else { 106 | jobHTMLURL := fmt.Sprintf("https://travis-ci.org/%s/%s/jobs/%d", context.Repo.Owner, context.Repo.Name, jobID) 107 | issue, _, err := context.GitHub.Issues.Create( 108 | context.Context(), 109 | context.Repo.Owner, context.Repo.Name, 110 | &github.IssueRequest{ 111 | Title: github.String("fmt build is failing on master"), 112 | Body: github.String(fmt.Sprintf( 113 | "Hey @jekyll/maintainers!\n\nIt looks like the fmt build in Travis is failing again: %s :frowning_face:\n\nCould someone please fix this up? Clone down the repo, run `bundle install`, then `script/fmt` to see the failures. File a PR once you're done and say \"Fixes \" in the description.\n\nThanks! :revolving_hearts:", 114 | jobHTMLURL, 115 | )), 116 | Labels: &failingFmtBuildLabels, 117 | }) 118 | if err != nil { 119 | return context.NewError("FailingFmtBuildHandler: failed to file an issue: %+v", err) 120 | } 121 | log.Printf("Filed issue: %s", *issue.HTMLURL) 122 | } 123 | break // you found the right job, now c'est fin 124 | } 125 | } 126 | 127 | return nil 128 | } 129 | 130 | func httpGetTravis(uri string) (*http.Response, error) { 131 | url := travisAPIBaseURL + uri 132 | req, err := http.NewRequest("GET", url, nil) 133 | if err != nil { 134 | return nil, fmt.Errorf("couldn't create request: %+v", err) 135 | } 136 | req.Header.Add("Accept", travisAPIContentType) 137 | resp, err := http.DefaultClient.Do(req) 138 | if err != nil { 139 | return nil, fmt.Errorf("couldn't send request to %s: %+v", url, err) 140 | } 141 | return resp, err 142 | } 143 | 144 | func buildIDFromTargetURL(targetURL string) (int64, error) { 145 | pieces := strings.Split(targetURL, "/") 146 | return strconv.ParseInt(pieces[len(pieces)-1], 10, 64) 147 | } 148 | -------------------------------------------------------------------------------- /travis/failing_fmt_build_test.go: -------------------------------------------------------------------------------- 1 | package travis 2 | 3 | import "testing" 4 | 5 | func TestBuildIDFromTargetURL(t *testing.T) { 6 | expected := int64(215667761) 7 | targetURL := "https://travis-ci.org/jekyll/jekyll/builds/215667761" 8 | actual, err := buildIDFromTargetURL(targetURL) 9 | if err != nil { 10 | t.Errorf("failed: expected no error but got %+v", err) 11 | } 12 | if actual != expected { 13 | t.Errorf("failed: expected %d from %q, but got: %d", expected, targetURL, actual) 14 | } 15 | } 16 | --------------------------------------------------------------------------------