├── .gitignore ├── AUTHORS ├── Dockerfile ├── LICENSE.md ├── MAINTAINERS ├── README.md ├── cmd ├── issues │ ├── MAINTAINERS │ ├── commands.go │ └── main.go └── pulls │ ├── MAINTAINERS │ ├── commands.go │ └── main.go ├── go.mod ├── go.sum └── pkg ├── filters └── filters.go ├── gordon ├── color.go ├── display.go ├── github.go ├── maintainers.go ├── review.go ├── sorter.go ├── utils.go └── version.go └── legacy ├── AUTHORS ├── Dockerfile ├── LICENSE ├── MAINTAINERS ├── NOTICE ├── README.md ├── adapters ├── __init__.py ├── git.py └── redis.py ├── api ├── __init__.py ├── adapters.py ├── model.py └── router.py ├── build-cache ├── build.py ├── init └── hooks.py ├── launch ├── listeners ├── __init__.py └── pulls.py ├── providers ├── __init__.py └── cache.py ├── requirements.txt ├── tasks ├── __init__.py └── cache.py ├── tests ├── __init__.py ├── integration │ ├── data.json │ └── test.sh └── mock.py └── web ├── __init__.py ├── app.py ├── config.py ├── controller.py ├── model.py ├── server.py ├── static └── css │ └── pure-min.css ├── templates ├── cache.html ├── index.html └── nav.html └── views.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.orig 2 | .idea 3 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | # This file lists all individuals having contributed content to the repository. 2 | # If you're submitting a patch, please add your name here in alphabetical order as part of the patch. 3 | # 4 | # For a list of active project maintainers, see the MAINTAINERS file. 5 | # 6 | Frederick F. Kautz IV 7 | Michael Crosby 8 | Solomon Hykes 9 | Victor Vieux 10 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM debian:wheezy 2 | MAINTAINER Sven Dowideit (@SvenDowideit) 3 | # 4 | # Run gordon in a container 5 | # `pulls() { docker run --rm -it -v $PWD:/src --workdir /src -e HOME=/src gordon pulls $@; }` 6 | # 7 | 8 | # Packaged dependencies 9 | RUN apt-get update && apt-get install -yq --no-install-recommends build-essential ca-certificates curl git mercurial vim-tiny 10 | 11 | # Install Go from binary release 12 | RUN curl -s https://go.googlecode.com/files/go1.2.1.linux-amd64.tar.gz | tar -v -C /usr/local -xz 13 | ENV PATH /go/bin:/usr/local/go/bin:$PATH 14 | ENV GOPATH /go 15 | 16 | RUN go get -u github.com/dotcloud/gordon/pulls 17 | RUN go get -u github.com/dotcloud/gordon/issues 18 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013 Michael Crosby and the pulls authors. 2 | 3 | Permission is hereby granted, free of charge, to any person 4 | obtaining a copy of this software and associated documentation 5 | files (the "Software"), to deal in the Software without 6 | restriction, including without limitation the rights to use, copy, 7 | modify, merge, publish, distribute, sublicense, and/or sell copies 8 | of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, 16 | INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 18 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 19 | HOLDERS BE LIABLE FOR ANY CLAIM, 20 | DAMAGES OR OTHER LIABILITY, 21 | WHETHER IN AN ACTION OF CONTRACT, 22 | TORT OR OTHERWISE, 23 | ARISING FROM, OUT OF OR IN CONNECTION WITH 24 | THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 25 | -------------------------------------------------------------------------------- /MAINTAINERS: -------------------------------------------------------------------------------- 1 | Erik Hollensbe (@erikh) 2 | Tianon Gravi (@tianon) 3 | Frederick F Kautz IV (@ffkiv) 4 | maintainers.go: Michael Crosby (@crosbymichael) 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Pulls 2 | 3 | Pulls is a small cli application name to help you manage pull requests for your repository. 4 | It was created by Michael Crosby to improve the productivity of the [Docker](https://docker.io) maintainers. 5 | 6 | Gordon assumes that the git `origin` is the upstream repository where the Issues and Pull Requests are managed. 7 | This is _not_ the workflow described in the [GitHub fork a repository](https://help.github.com/articles/fork-a-repo) 8 | documentation. 9 | 10 | Quick installation instructions: 11 | 12 | * Install Go 1.2+ from http://golang.org/ 13 | * Install with `go get -u github.com/docker/gordon/{pulls,issues}` 14 | * Make sure your `$PATH` includes *x*/bin where *x* is each directory in your `$GOPATH` environment variable. 15 | * Call `pulls --help` and `issues --help` 16 | * Add your github token with `pulls auth --add ` 17 | 18 | Dockerfile container build: 19 | 20 | If you don't have Go set up and want to try out Gordon, you can use the Dockerfile to build it, and then 21 | can either copy the 2 executables to your local Linux host: 22 | 23 | - Build: `docker build -t gordon .` 24 | - Copy: `docker run --name gore gordon true && docker cp gore:/go/bin/pulls . && docker cp gore:/go/bin/issues . && docker rm gore`` 25 | 26 | You could also run from inside the container: 27 | - Setup an alias: `pulls() { docker run --rm -it -v $PWD:/src --workdir /src -e HOME=/src gordon pulls $@; }` 28 | - Set the GitHub API token: `pulls auth SvenDowideit --add 1373a7583d30623abcb2b233fe45090fe2e4a3e1a2` 29 | - List open PR's: `pulls` 30 | -------------------------------------------------------------------------------- /cmd/issues/MAINTAINERS: -------------------------------------------------------------------------------- 1 | Roberto Hashioka (@rogaha) 2 | -------------------------------------------------------------------------------- /cmd/issues/commands.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/urfave/cli" 5 | "github.com/docker/gordon/pkg/gordon" 6 | ) 7 | 8 | func loadCommands(app *cli.App) { 9 | app.Action = mainCmd 10 | 11 | app.Flags = []cli.Flag{ 12 | cli.StringFlag{Name: "assigned", Value: "", Usage: "display issues assigned to . Use '*' for all assigned, or 'none' for all unassigned."}, 13 | cli.StringFlag{Name: "remote", Value: gordon.GetDefaultGitRemote(), Usage: "git remote to treat as origin"}, 14 | cli.StringFlag{Name: "milestone", Value: "", Usage: "display issues inside a particular ."}, 15 | cli.BoolFlag{Name: "no-trunc", Usage: "do not truncate the issue name"}, 16 | cli.BoolFlag{Name: "verbose", Usage: "show more verbose output on actions"}, 17 | cli.IntFlag{Name: "votes", Value: -1, Usage: "display the number of votes '+1' filtered by the specified."}, 18 | cli.BoolFlag{Name: "vote", Usage: "add '+1' to an specific issue."}, 19 | cli.BoolFlag{Name: "proposals", Usage: "Only show proposal issues"}, 20 | } 21 | 22 | app.Commands = []cli.Command{ 23 | { 24 | Name: "alru", 25 | Usage: "Show the Age of the Least Recently Updated issue for this repo. Lower is better.", 26 | Action: alruCmd, 27 | }, 28 | { 29 | Name: "repo", 30 | Usage: "List information about the current repository", 31 | Action: repositoryInfoCmd, 32 | }, 33 | { 34 | Name: "take", 35 | Usage: "Assign an issue to your github account", 36 | Action: takeCmd, 37 | Flags: []cli.Flag{ 38 | cli.BoolFlag{Name: "overwrite", Usage: "overwrites a taken issue"}, 39 | }, 40 | }, 41 | { 42 | Name: "close", 43 | Usage: "Close an issue", 44 | Description: "Provide the issue number for issue(s) to close for this repository", 45 | Action: closeCmd, 46 | Flags: []cli.Flag{}, 47 | }, 48 | { 49 | Name: "search", 50 | Usage: "Find issues by state and keyword.", 51 | Action: searchCmd, 52 | Flags: []cli.Flag{ 53 | cli.StringFlag{Name: "author", Value: "", Usage: "Finds issues created by a certain user"}, 54 | cli.StringFlag{Name: "assignee", Value: "", Usage: "Finds issues that are assigned to a certain user"}, 55 | cli.StringFlag{Name: "mentions", Value: "", Usage: "Finds issues that mention a certain user"}, 56 | cli.StringFlag{Name: "commenter", Value: "", Usage: "Finds issues that a certain user commented on"}, 57 | cli.StringFlag{Name: "involves", Value: "", Usage: "Finds issues that were either created by a certain user, assigned to that user, mention that user, or were commented on by that user"}, 58 | cli.StringFlag{Name: "labels", Value: "", Usage: "Filters issues based on their labels"}, 59 | cli.StringFlag{Name: "state", Value: "", Usage: "Filter issues based on whether they’re open or closed"}, 60 | }, 61 | }, 62 | { 63 | Name: "auth", 64 | Usage: "Add a github token for authentication", 65 | Action: authCmd, 66 | Flags: []cli.Flag{ 67 | cli.StringFlag{Name: "add", Value: "", Usage: "add new token for authentication"}, 68 | }, 69 | }, 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /cmd/issues/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "github.com/docker/gordon/pkg/gordon" 6 | "os" 7 | "path" 8 | "time" 9 | 10 | "github.com/urfave/cli" 11 | gh "github.com/crosbymichael/octokat" 12 | "github.com/docker/gordon/pkg/filters" 13 | ) 14 | 15 | var ( 16 | m *gordon.MaintainerManager 17 | remote_origin = "origin" 18 | configPath = path.Join(os.Getenv("HOME"), ".maintainercfg") 19 | ) 20 | 21 | func alruCmd(c *cli.Context) error { 22 | lru, err := m.GetFirstIssue("open", "updated") 23 | if err != nil { 24 | gordon.Fatalf("Error getting issues: %s", err) 25 | } 26 | fmt.Printf("%v (#%d)\n", gordon.HumanDuration(time.Since(lru.UpdatedAt)), lru.Number) 27 | return nil 28 | } 29 | 30 | func repositoryInfoCmd(c *cli.Context) error { 31 | r, err := m.Repository() 32 | if err != nil { 33 | gordon.Fatalf("%s", err) 34 | } 35 | fmt.Printf("Name: %s\nForks: %d\nStars: %d\nIssues: %d\n", r.Name, r.Forks, r.Watchers, r.OpenIssues) 36 | return nil 37 | } 38 | 39 | //Take a specific issue. If it's taken, show a message with the overwrite optional flag 40 | //If the user doesn't have permissions, add a comment #volunteer 41 | func takeCmd(c *cli.Context) error { 42 | if c.Args().Present() { 43 | number := c.Args()[0] 44 | issue, _, err := m.GetIssue(number, false) 45 | if err != nil { 46 | gordon.Fatalf("%s", err) 47 | } 48 | user, err := m.GetGithubUser() 49 | if err != nil { 50 | gordon.Fatalf("%s", err) 51 | } 52 | if user == nil { 53 | gordon.Fatalf("%v", gordon.ErrNoUsernameKnown) 54 | } 55 | if issue.Assignee.Login != "" && !c.Bool("overwrite") { 56 | fmt.Printf("Use the flag --overwrite to take the issue from %s\n", issue.Assignee.Login) 57 | return nil 58 | } 59 | issue.Assignee = *user 60 | patchedIssue, err := m.PatchIssue(number, issue) 61 | if err != nil { 62 | gordon.Fatalf("%s", err) 63 | } 64 | if patchedIssue.Assignee.Login != user.Login { 65 | m.AddComment(number, "#volunteer") 66 | fmt.Printf("No permission to assign. You '%s' was added as #volunteer.\n", user.Login) 67 | } else { 68 | fmt.Printf("The issue %s was assigned to %s\n", number, patchedIssue.Assignee.Login) 69 | } 70 | } else { 71 | fmt.Printf("Please enter the issue's number\n") 72 | } 73 | return nil 74 | } 75 | 76 | func closeCmd(c *cli.Context) error { 77 | if !c.Args().Present() { 78 | gordon.Fatalf("Please enter the issue's number") 79 | } 80 | number := c.Args()[0] 81 | if err := m.Close(number); err != nil { 82 | gordon.Fatalf("%v", err) 83 | } 84 | fmt.Printf("Closed issue %s\n", number) 85 | return nil 86 | } 87 | 88 | func buildQuery(c *cli.Context) string { 89 | r, err := m.Repository() 90 | if err != nil { 91 | gordon.Fatalf("%s", err) 92 | } 93 | // standard parameters 94 | query := fmt.Sprintf("q=%s+repo:%s", c.Args()[0], r.FullName) 95 | state := c.String("state") 96 | if state == "" { 97 | state = "open" 98 | } 99 | query += fmt.Sprintf("+state:%s", state) 100 | // optional parameters 101 | var optionalParameters = []string{ 102 | "author", 103 | "assignee", 104 | "mentions", 105 | "commenter", 106 | "involves", 107 | "labels"} 108 | 109 | for i := 0; i < len(optionalParameters); i++ { 110 | param := optionalParameters[i] 111 | value := c.String(param) 112 | if value != "" { 113 | query += fmt.Sprintf("+%s:%s", param, value) 114 | } 115 | } 116 | return query 117 | } 118 | 119 | //Search for issues. You add some restrictions to the query. such: 120 | // authors, assignee, state, etc. Check the command help for more options. 121 | func searchCmd(c *cli.Context) error { 122 | if c.Args().Present() { 123 | issues, err := m.GetIssuesFound(buildQuery(c)) 124 | if err != nil { 125 | gordon.Fatalf("%s", err) 126 | } 127 | fmt.Printf("%c[2K\r", 27) 128 | gordon.DisplayIssues(c, issues, c.Bool("no-trunc")) 129 | } else { 130 | fmt.Printf("Please enter a search term\n") 131 | } 132 | return nil 133 | } 134 | 135 | func addComment(number, comment string) { 136 | cmt, err := m.AddComment(number, comment) 137 | if err != nil { 138 | gordon.Fatalf("%s", err) 139 | } 140 | gordon.DisplayCommentAdded(cmt) 141 | } 142 | 143 | func mainCmd(c *cli.Context) error { 144 | if !c.Args().Present() { 145 | var issues, err = m.GetIssues("open", c.String("assigned")) 146 | 147 | if err != nil { 148 | gordon.Fatalf("Error getting issues: %s", err) 149 | } 150 | issues, err = filters.FilterIssues(c, issues) 151 | if err != nil { 152 | gordon.Fatalf("Error filtering issues: %s", err) 153 | } 154 | 155 | fmt.Printf("%c[2K\r", 27) 156 | gordon.DisplayIssues(c, issues, c.Bool("no-trunc")) 157 | return nil 158 | } 159 | 160 | var ( 161 | number = c.Args().Get(0) 162 | comment = c.String("comment") 163 | ) 164 | 165 | if comment != "" { 166 | addComment(number, comment) 167 | return nil 168 | } 169 | 170 | if c.Bool("vote") { 171 | addComment(number, "+1") 172 | fmt.Printf("Vote added to the issue: %s", number) 173 | return nil 174 | } 175 | 176 | issue, comments, err := m.GetIssue(number, true) 177 | if err != nil { 178 | gordon.Fatalf("%s", err) 179 | } 180 | gordon.DisplayIssue(issue, comments) 181 | return nil 182 | } 183 | 184 | func authCmd(c *cli.Context) error { 185 | config, err := gordon.LoadConfig() 186 | if err != nil { 187 | config = &gordon.Config{} 188 | } 189 | token := c.String("add") 190 | userName := c.String("user") 191 | if userName != "" { 192 | config.UserName = userName 193 | if err := gordon.SaveConfig(*config); err != nil { 194 | gordon.Fatalf("%s", err) 195 | } 196 | } 197 | if token != "" { 198 | config.Token = token 199 | if err := gordon.SaveConfig(*config); err != nil { 200 | gordon.Fatalf("%s", err) 201 | } 202 | } 203 | // Display token and user information 204 | if config, err := gordon.LoadConfig(); err == nil { 205 | if config.UserName != "" { 206 | fmt.Printf("Token: %s, UserName: %s\n", config.Token, config.UserName) 207 | } else { 208 | 209 | fmt.Printf("Token: %s\n", config.Token) 210 | } 211 | } else { 212 | fmt.Fprintf(os.Stderr, "No token registered\n") 213 | os.Exit(1) 214 | } 215 | return nil 216 | } 217 | 218 | func before(c *cli.Context) error { 219 | client := gh.NewClient() 220 | 221 | // set up the git remote to be used 222 | org, name, err := gordon.GetRemoteUrl(c.String("remote")) 223 | if err != nil { 224 | return fmt.Errorf("The current directory is not a valid git repository (%s).\n", err) 225 | } 226 | t, err := gordon.NewMaintainerManager(client, org, name) 227 | if err != nil { 228 | return err 229 | } 230 | m = t 231 | 232 | // Set verbosity 233 | gordon.VerboseOutput = c.Bool("verbose") 234 | 235 | return nil 236 | } 237 | 238 | func main() { 239 | app := cli.NewApp() 240 | 241 | app.Name = "issues" 242 | app.Usage = "Manage github issues" 243 | app.Version = gordon.Version 244 | 245 | app.Before = before 246 | loadCommands(app) 247 | 248 | err := app.Run(os.Args) 249 | if err != nil { 250 | gordon.Fatalf(err.Error()) 251 | } 252 | } 253 | -------------------------------------------------------------------------------- /cmd/pulls/MAINTAINERS: -------------------------------------------------------------------------------- 1 | Solomon Hykes (@shykes) 2 | Victor Vieux (@vieux) 3 | -------------------------------------------------------------------------------- /cmd/pulls/commands.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/urfave/cli" 5 | "github.com/docker/gordon/pkg/gordon" 6 | ) 7 | 8 | func loadCommands(app *cli.App) { 9 | // Add top level flags and commands 10 | app.Action = mainCmd 11 | 12 | app.Flags = []cli.Flag{ 13 | cli.StringFlag{Name: "remote", Value: gordon.GetDefaultGitRemote(), Usage: "git remote to treat as origin"}, 14 | cli.BoolFlag{Name: "verbose", Usage: "show more verbose output on actions"}, 15 | } 16 | 17 | // Filters modify what type of pr to display 18 | filters := []cli.Flag{ 19 | cli.BoolFlag{Name: "no-merge", Usage: "display only prs that cannot be merged"}, 20 | cli.BoolFlag{Name: "lgtm", Usage: "display the number of LGTM"}, 21 | cli.StringFlag{Name: "state", Value: "open", Usage: "display prs based on their state"}, 22 | cli.BoolFlag{Name: "new", Usage: "display prs opened in the last 24 hours"}, 23 | cli.BoolFlag{Name: "mine", Usage: "display only PRs I care about based on the MAINTAINERS files"}, 24 | cli.StringFlag{Name: "maintainer", Value: "", Usage: "display only PRs a maintainer cares about based on the MAINTAINERS files"}, 25 | cli.StringFlag{Name: "sort", Value: "updated", Usage: "sort the prs by (created, updated, popularity, long-running)"}, 26 | cli.StringFlag{Name: "assigned", Value: "", Usage: "display only prs assigned to a user"}, 27 | cli.BoolFlag{Name: "unassigned", Usage: "display only unassigned prs"}, 28 | cli.StringFlag{Name: "dir", Value: "", Usage: "display only prs that touch this dir"}, 29 | cli.StringFlag{Name: "extension", Value: "", Usage: "display only prs that have files with this extension (no dot)"}, 30 | cli.BoolFlag{Name: "cleanup", Usage: "display only cleanup prs"}, 31 | } 32 | app.Flags = append(app.Flags, filters...) 33 | 34 | // Options modify how to display prs 35 | options := []cli.Flag{ 36 | cli.BoolFlag{Name: "no-trunc", Usage: "don't truncate pr name"}, 37 | cli.StringFlag{Name: "user", Value: "", Usage: "display only prs from "}, 38 | cli.StringFlag{Name: "comment", Value: "", Usage: "add a comment to the pr"}, 39 | } 40 | app.Flags = append(app.Flags, options...) 41 | 42 | // Add subcommands 43 | app.Commands = []cli.Command{ 44 | { 45 | Name: "repo", 46 | Usage: "List information about the current repository", 47 | Action: repositoryInfoCmd, 48 | }, 49 | { 50 | Name: "comment", 51 | Usage: "Leave a comment on a pull request", 52 | Action: commentCmd, 53 | Flags: []cli.Flag{ 54 | cli.StringFlag{Name: "template", Usage: "provide a template for commenting"}, 55 | }, 56 | }, 57 | { 58 | Name: "comments", 59 | Usage: "Show comments on a pull request", 60 | Action: commentsCmd, 61 | }, 62 | { 63 | Name: "auth", 64 | Usage: "Add a github token for authentication", 65 | Action: authCmd, 66 | Flags: []cli.Flag{ 67 | cli.StringFlag{Name: "add", Value: "", Usage: "add new token for authentication"}, 68 | cli.StringFlag{Name: "user", Value: "", Usage: "add github user name"}, 69 | }, 70 | }, 71 | { 72 | Name: "alru", 73 | Usage: "Show the Age of the Least Recently Updated pull request for this repo. Lower is better.", 74 | Action: alruCmd, 75 | }, 76 | { 77 | Name: "merge", 78 | Usage: "Merge a pull request", 79 | Action: mergeCmd, 80 | Flags: []cli.Flag{ 81 | cli.StringFlag{Name: "m", Value: "", Usage: "commit message for merge"}, 82 | cli.BoolFlag{Name: "force", Usage: "merge a pull request that has not been approved"}, 83 | }, 84 | }, 85 | { 86 | Name: "close", 87 | Usage: "Close a pull request without merging it", 88 | Action: closeCmd, 89 | Flags: []cli.Flag{}, 90 | }, 91 | { 92 | Name: "checkout", 93 | Usage: "Checkout a pull request into your local repo", 94 | Action: checkoutCmd, 95 | }, 96 | { 97 | Name: "send", 98 | Usage: "Send a new pull request, or overwrite an existing one", 99 | Action: sendCmd, 100 | }, 101 | { 102 | Name: "approve", 103 | Usage: "Approve a pull request by adding LGTM to the comments", 104 | Action: approveCmd, 105 | }, 106 | { 107 | Name: "take", 108 | Usage: "Assign a pull request to your github account", 109 | Action: takeCmd, 110 | Flags: []cli.Flag{ 111 | cli.BoolFlag{Name: "steal", Usage: "steal the pull request from its current owner"}, 112 | }, 113 | }, 114 | { 115 | Name: "drop", 116 | Usage: "Give up ownership of a pull request assigned to you", 117 | Action: dropCmd, 118 | Flags: []cli.Flag{}, 119 | }, 120 | { 121 | Name: "diff", 122 | Usage: "Print the patch submitted by a pull request", 123 | Action: showCmd, 124 | }, 125 | { 126 | Name: "reviewers", 127 | Usage: "Use the hierarchy of MAINTAINERS files to list who should review a pull request", 128 | Action: reviewersCmd, 129 | }, 130 | { 131 | Name: "contributors", 132 | Usage: "Show the contributors list with additions, deletions, and commit counts. Default: sorted by Commits", 133 | Action: contributorsCmd, 134 | Flags: []cli.Flag{ 135 | cli.BoolFlag{Name: "additions", Usage: "sort by additions"}, 136 | cli.BoolFlag{Name: "deletions", Usage: "sort by deletions"}, 137 | cli.BoolFlag{Name: "commits", Usage: "sort by commits"}, 138 | cli.IntFlag{Name: "top", Value: 10, Usage: "top N contributors"}, 139 | }, 140 | }, 141 | { 142 | Name: "compare", 143 | Usage: "Compare two branches to simplify the creation of patch merge pull requests.", 144 | Action: compareCmd, 145 | }, 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /cmd/pulls/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "github.com/docker/gordon/pkg/gordon" 7 | "io" 8 | "io/ioutil" 9 | "net/http" 10 | "os" 11 | "os/exec" 12 | "path/filepath" 13 | "regexp" 14 | "strings" 15 | "time" 16 | 17 | "github.com/aybabtme/color/brush" 18 | "github.com/urfave/cli" 19 | gh "github.com/crosbymichael/octokat" 20 | "github.com/docker/gordon/pkg/filters" 21 | ) 22 | 23 | var ( 24 | m *gordon.MaintainerManager 25 | templatePath = filepath.Join(os.Getenv("HOME"), ".gordon/templates") 26 | ) 27 | 28 | func displayAllPullRequests(c *cli.Context) error { 29 | prs, err := m.GetPullRequests(c.String("state"), c.String("sort")) 30 | if err != nil { 31 | gordon.Fatalf("Error getting pull requests %s", err) 32 | } 33 | 34 | var needFullPr, needComments bool 35 | 36 | if c.Bool("no-merge") { 37 | needFullPr = true 38 | } 39 | if c.Bool("lgtm") { 40 | needComments = true 41 | } 42 | 43 | if needFullPr || needComments { 44 | prs = m.GetFullPullRequests(prs, needFullPr, needComments) 45 | } 46 | 47 | prs, err = filters.FilterPullRequests(c, prs) 48 | if err != nil { 49 | gordon.Fatalf("Error filtering pull requests %s", err) 50 | } 51 | 52 | fmt.Printf("%c[2K\r", 27) 53 | gordon.DisplayPullRequests(c, prs, c.Bool("no-trunc")) 54 | return nil 55 | } 56 | 57 | func displayAllPullRequestFiles(c *cli.Context, number string) error { 58 | prfs, err := m.GetPullRequestFiles(number) 59 | if err == nil { 60 | i := 1 61 | for _, p := range prfs { 62 | fmt.Printf("%d: filename %s additions %d deletions %d\n", i, p.FileName, p.Additions, p.Deletions) 63 | i++ 64 | } 65 | } 66 | return err 67 | } 68 | 69 | func alruCmd(c *cli.Context) error { 70 | lru, err := m.GetFirstPullRequest("open", "updated") 71 | if err != nil { 72 | gordon.Fatalf("Error getting pull requests: %s", err) 73 | } 74 | fmt.Printf("%v (#%d)\n", gordon.HumanDuration(time.Since(lru.UpdatedAt)), lru.Number) 75 | return nil 76 | } 77 | 78 | func addComment(number, comment string) { 79 | cmt, err := m.AddComment(number, comment) 80 | if err != nil { 81 | gordon.Fatalf("%s", err) 82 | } 83 | gordon.DisplayCommentAdded(cmt) 84 | } 85 | 86 | func repositoryInfoCmd(c *cli.Context) error { 87 | r, err := m.Repository() 88 | if err != nil { 89 | gordon.Fatalf("%s", err) 90 | } 91 | fmt.Printf("Name: %s\nForks: %d\nStars: %d\nIssues: %d\n", r.Name, r.Forks, r.Watchers, r.OpenIssues) 92 | return nil 93 | } 94 | 95 | func mergeCmd(c *cli.Context) error { 96 | if !c.Args().Present() { 97 | gordon.Fatalf("usage: merge ID") 98 | } 99 | number := c.Args()[0] 100 | merge, err := m.MergePullRequest(number, c.String("m"), c.Bool("force")) 101 | if err != nil { 102 | gordon.Fatalf("%s", err) 103 | } 104 | if merge.Merged { 105 | fmt.Printf("%s\n", brush.Green(merge.Message)) 106 | } else { 107 | gordon.Fatalf("%s", err) 108 | } 109 | return nil 110 | } 111 | 112 | func checkoutCmd(c *cli.Context) error { 113 | if !c.Args().Present() { 114 | gordon.Fatalf("usage: checkout ID") 115 | } 116 | number := c.Args()[0] 117 | pr, err := m.GetPullRequest(number) 118 | if err != nil { 119 | gordon.Fatalf("%s", err) 120 | } 121 | if err := m.Checkout(pr); err != nil { 122 | gordon.Fatalf("%s", err) 123 | } 124 | return nil 125 | } 126 | 127 | // Approve a pr by adding a LGTM to the comments 128 | func approveCmd(c *cli.Context) error { 129 | if !c.Args().Present() { 130 | gordon.Fatalf("usage: approve ID") 131 | } 132 | number := c.Args().First() 133 | if _, err := m.AddComment(number, "LGTM"); err != nil { 134 | gordon.Fatalf("%s", err) 135 | } 136 | fmt.Printf("Pull request %s approved\n", brush.Green(number)) 137 | return nil 138 | } 139 | 140 | // Show the patch in a PR 141 | func showCmd(c *cli.Context) error { 142 | if !c.Args().Present() { 143 | gordon.Fatalf("usage: show ID") 144 | } 145 | number := c.Args()[0] 146 | pr, err := m.GetPullRequest(number) 147 | if err != nil { 148 | gordon.Fatalf("%s", err) 149 | } 150 | patch, err := http.Get(pr.DiffURL) 151 | if err != nil { 152 | gordon.Fatalf("%s", err) 153 | } 154 | defer patch.Body.Close() 155 | 156 | if err := gordon.DisplayPatch(patch.Body); err != nil { 157 | gordon.Fatalf("%s", err) 158 | } 159 | return nil 160 | } 161 | 162 | // Show contributors stats 163 | func contributorsCmd(c *cli.Context) error { 164 | contributors, err := m.GetContributors() 165 | if err != nil { 166 | gordon.Fatalf("%s", err) 167 | } 168 | gordon.DisplayContributors(c, contributors) 169 | return nil 170 | } 171 | 172 | // Show the reviewers for this pull request 173 | func reviewersCmd(c *cli.Context) error { 174 | if !c.Args().Present() { 175 | gordon.Fatalf("usage: reviewers ID") 176 | } 177 | 178 | var ( 179 | patch io.Reader 180 | patchBytes []byte 181 | number = c.Args()[0] 182 | ) 183 | 184 | if number == "-" { 185 | patch = os.Stdin 186 | } else { 187 | pr, err := m.GetPullRequest(number) 188 | if err != nil { 189 | gordon.Fatalf("%s", err) 190 | } 191 | 192 | resp, err := http.Get(pr.DiffURL) 193 | if err != nil { 194 | gordon.Fatalf("%s", err) 195 | } 196 | patch = resp.Body 197 | defer resp.Body.Close() 198 | } 199 | 200 | patchBytes, err := ioutil.ReadAll(patch) 201 | if err != nil { 202 | gordon.Fatalf("%s", err) 203 | } 204 | 205 | reviewers, err := gordon.GetReviewersForPR(patchBytes, false) 206 | if err != nil { 207 | gordon.Fatalf("%s", err) 208 | } 209 | gordon.DisplayReviewers(c, reviewers) 210 | return nil 211 | } 212 | 213 | // This is the top level command for 214 | // working with prs 215 | func mainCmd(c *cli.Context) error { 216 | if !c.Args().Present() { 217 | return displayAllPullRequests(c) 218 | } 219 | 220 | var ( 221 | number = c.Args().Get(0) 222 | comment = c.String("comment") 223 | ) 224 | 225 | if comment != "" { 226 | addComment(number, comment) 227 | return nil 228 | } 229 | pr, err := m.GetPullRequest(number) 230 | if err != nil { 231 | gordon.Fatalf("%s", err) 232 | } 233 | status, err := m.GetStatus(pr) 234 | gordon.DisplayPullRequest(pr, status) 235 | return nil 236 | } 237 | 238 | func commentsCmd(c *cli.Context) error { 239 | if !c.Args().Present() { 240 | gordon.Fatalf("usage: comments ID") 241 | } 242 | comments, err := m.GetComments(c.Args().First()) 243 | if err != nil { 244 | gordon.Fatalf("%s", err) 245 | } 246 | gordon.DisplayComments(comments) 247 | return nil 248 | } 249 | 250 | func authCmd(c *cli.Context) error { 251 | config, err := gordon.LoadConfig() 252 | if err != nil { 253 | config = &gordon.Config{} 254 | } 255 | token := c.String("add") 256 | userName := c.String("user") 257 | if userName != "" { 258 | config.UserName = userName 259 | if err := gordon.SaveConfig(*config); err != nil { 260 | gordon.Fatalf("%s", err) 261 | } 262 | } 263 | if token != "" { 264 | config.Token = token 265 | if err := gordon.SaveConfig(*config); err != nil { 266 | gordon.Fatalf("%s", err) 267 | } 268 | } 269 | // Display token and user information 270 | if config, err := gordon.LoadConfig(); err == nil { 271 | if config.UserName != "" { 272 | fmt.Printf("Token: %s, UserName: %s\n", config.Token, config.UserName) 273 | } else { 274 | 275 | fmt.Printf("Token: %s\n", config.Token) 276 | } 277 | } else { 278 | fmt.Fprintf(os.Stderr, "No token registered\n") 279 | os.Exit(1) 280 | } 281 | return nil 282 | } 283 | 284 | //Assign a pull request to the current user. 285 | // If it's taken, show a message with the "--steal" optional flag. 286 | //If the user doesn't have permissions, add a comment #volunteer 287 | func takeCmd(c *cli.Context) error { 288 | if !c.Args().Present() { 289 | gordon.Fatalf("usage: take ID") 290 | } 291 | number := c.Args()[0] 292 | pr, err := m.GetPullRequest(number) 293 | if err != nil { 294 | gordon.Fatalf("%s", err) 295 | } 296 | user, err := m.GetGithubUser() 297 | if err != nil { 298 | gordon.Fatalf("%s", err) 299 | } 300 | if user == nil { 301 | gordon.Fatalf("%v", gordon.ErrNoUsernameKnown) 302 | } 303 | if pr.Assignee != nil && !c.Bool("steal") { 304 | gordon.Fatalf("Use --steal to steal the PR from %s", pr.Assignee.Login) 305 | } 306 | pr.Assignee = user 307 | patchedPR, err := m.PatchPullRequest(number, pr) 308 | if err != nil { 309 | gordon.Fatalf("%s", err) 310 | } 311 | if patchedPR.Assignee.Login != user.Login { 312 | m.AddComment(number, "#volunteer") 313 | fmt.Printf("No permission to assign. You '%s' was added as #volunteer.\n", user.Login) 314 | } else { 315 | m.AddComment(number, fmt.Sprintf("#assignee=%s", patchedPR.Assignee.Login)) 316 | fmt.Printf("Assigned PR %s to %s\n", brush.Green(number), patchedPR.Assignee.Login) 317 | } 318 | return nil 319 | } 320 | 321 | func dropCmd(c *cli.Context) error { 322 | if !c.Args().Present() { 323 | gordon.Fatalf("usage: drop ID") 324 | } 325 | number := c.Args()[0] 326 | pr, err := m.GetPullRequest(number) 327 | if err != nil { 328 | gordon.Fatalf("%s", err) 329 | } 330 | user, err := m.GetGithubUser() 331 | if err != nil { 332 | gordon.Fatalf("%s", err) 333 | } 334 | if user == nil { 335 | gordon.Fatalf("%v", gordon.ErrNoUsernameKnown) 336 | } 337 | if pr.Assignee == nil || pr.Assignee.Login != user.Login { 338 | gordon.Fatalf("Can't drop %s: it's not yours.", number) 339 | } 340 | pr.Assignee = nil 341 | if _, err := m.PatchPullRequest(number, pr); err != nil { 342 | gordon.Fatalf("%s", err) 343 | } 344 | fmt.Printf("Unassigned PR %s\n", brush.Green(number)) 345 | return nil 346 | } 347 | 348 | func commentCmd(c *cli.Context) error { 349 | if !c.Args().Present() { 350 | gordon.Fatalf("Please enter the issue's number") 351 | } 352 | number := c.Args()[0] 353 | editor := os.Getenv("EDITOR") 354 | if editor == "" { 355 | editor = "nano" 356 | } 357 | tmp, err := ioutil.TempFile("", "pulls-comment-") 358 | if err != nil { 359 | gordon.Fatalf("%v", err) 360 | } 361 | defer os.Remove(tmp.Name()) 362 | 363 | if template := c.String("template"); template != "" { 364 | f, err := os.Open(template) 365 | 366 | if err != nil { 367 | path := filepath.Join(templatePath, filepath.Base(c.String("template"))) 368 | 369 | f, err = os.Open(path) 370 | if err != nil { 371 | gordon.Fatalf("%v", err) 372 | } 373 | } 374 | 375 | defer f.Close() 376 | 377 | if _, err := io.Copy(tmp, f); err != nil { 378 | gordon.Fatalf("%v", err) 379 | } 380 | } 381 | 382 | cmd := exec.Command(editor, tmp.Name()) 383 | cmd.Stdin = os.Stdin 384 | cmd.Stdout = os.Stdout 385 | cmd.Stderr = os.Stderr 386 | if err := cmd.Run(); err != nil { 387 | gordon.Fatalf("%v", err) 388 | } 389 | 390 | if _, err := tmp.Seek(0, 0); err != nil { 391 | gordon.Fatalf("%v", err) 392 | } 393 | 394 | comment, err := ioutil.ReadAll(tmp) 395 | if err != nil { 396 | gordon.Fatalf("%v", err) 397 | } 398 | 399 | if _, err := m.AddComment(number, string(comment)); err != nil { 400 | gordon.Fatalf("%v", err) 401 | } 402 | return nil 403 | } 404 | 405 | func closeCmd(c *cli.Context) error { 406 | if !c.Args().Present() { 407 | gordon.Fatalf("Please enter the issue's number") 408 | } 409 | number := c.Args()[0] 410 | if err := m.Close(number); err != nil { 411 | gordon.Fatalf("%v", err) 412 | } 413 | fmt.Printf("Closed PR %s\n", number) 414 | return nil 415 | } 416 | 417 | func sendCmd(c *cli.Context) error { 418 | if nArgs := len(c.Args()); nArgs == 0 { 419 | // Push the branch, then create the PR 420 | // Pick a remote branch name 421 | commitMsg, err := exec.Command("git", "log", "--no-merges", "-1", "--pretty=format:%s", "HEAD").CombinedOutput() 422 | if err != nil { 423 | gordon.Fatalf("git log: %v", err) 424 | } 425 | brName := "pr_out_" + gordon.GenBranchName(string(commitMsg)) 426 | fmt.Printf("remote branch = %s\n", brName) 427 | user, err := m.GetGithubUser() 428 | if err != nil { 429 | gordon.Fatalf("%v", err) 430 | } 431 | if user == nil { 432 | gordon.Fatalf("%v", gordon.ErrNoUsernameKnown) 433 | } 434 | 435 | repo, err := m.Repository() 436 | if err != nil { 437 | gordon.Fatalf("%v\n", err) 438 | } 439 | // FIXME: use the github API to get our fork's url (or create the fork if needed) 440 | if err := gordon.Git("push", "-f", fmt.Sprintf("ssh://git@github.com/%s/%s", user.Login, repo.Name), "HEAD:refs/heads/"+brName); err != nil { 441 | gordon.Fatalf("git push: %v", err) 442 | } 443 | prBase := "master" 444 | prHead := fmt.Sprintf("%s:%s", user.Login, brName) 445 | fmt.Printf("Creating pull request from %s to %s\n", prBase, prHead) 446 | pr, err := m.CreatePullRequest(prBase, prHead, string(commitMsg), "") 447 | if err != nil { 448 | gordon.Fatalf("create pull request: %v", err) 449 | } 450 | fmt.Printf("Created %v\n", pr.Number) 451 | } else if nArgs == 1 { 452 | pr, err := m.GetPullRequest(c.Args()[0]) 453 | if err != nil { 454 | gordon.Fatalf("%v", err) 455 | } 456 | if err := gordon.Git("push", "-f", pr.Head.Repo.SSHURL, "HEAD:"+pr.Head.Ref); err != nil { 457 | gordon.Fatalf("%v", err) 458 | } 459 | fmt.Printf("Overwrote %v\n", pr.Number) 460 | } else { 461 | gordon.Fatalf("Usage: send [ID]") 462 | } 463 | return nil 464 | } 465 | 466 | // I need to parse the output of git! 467 | func git(args ...string) (string, error) { 468 | cmd := exec.Command("git", args...) 469 | //PrintVerboseCommand(cmd) 470 | cmd.Stderr = os.Stderr 471 | // cmd.Stdout = os.Stdout 472 | 473 | b, err := cmd.Output() 474 | if err != nil { 475 | return "", err 476 | } 477 | out := string(b) 478 | return out, nil 479 | } 480 | 481 | // compareCmd searches to find Merge commits that are in master that are not in the branch 482 | func compareCmd(c *cli.Context) error { 483 | 484 | // TODO: don't repase all history to the begining of time (--after?) 485 | 486 | if nArgs := len(c.Args()); nArgs == 2 { 487 | // git log --format=oneline upstream/master > master.log 488 | masterLog, err := git("log", "--format=format:%H %s %b", c.Args()[0]) 489 | if err != nil { 490 | gordon.Fatalf("%v", err) 491 | } 492 | 493 | // git log --format=oneline upstream/docs > docs.log 494 | branchLog, err := git("log", "--format=format:%H %s %b", c.Args()[1]) 495 | if err != nil { 496 | gordon.Fatalf("%v", err) 497 | } 498 | 499 | // diff -U 0 master.log docs.log | grep "Merge pull" | sed "s/^-/- /g" 500 | // Parse both logs looking for 'Merge pull request #` 501 | reMergeLine := regexp.MustCompile(`^([0-9a-f]{40}) Merge pull request #([0-9]*) from (.*)$`) 502 | branchPRs := make(map[string]string) 503 | s := bufio.NewScanner(strings.NewReader(branchLog)) 504 | for s.Scan() { 505 | res := reMergeLine.FindStringSubmatch(s.Text()) 506 | if res == nil { 507 | continue 508 | } 509 | hash := res[1] 510 | pr := res[2] 511 | //desc := res[3] 512 | branchPRs[pr] = hash 513 | } 514 | 515 | firstMerged := "" 516 | s = bufio.NewScanner(strings.NewReader(masterLog)) 517 | for s.Scan() { 518 | res := reMergeLine.FindStringSubmatch(s.Text()) 519 | if res == nil { 520 | continue 521 | } 522 | hash := res[1] 523 | pr := res[2] 524 | desc := res[3] 525 | 526 | if branchPRs[pr] == "" { 527 | // TODO: consider reversing the order of this list to match the commit list in the GH PR 528 | fmt.Printf("- %s #%s : %s\n", hash, pr, desc) 529 | } else if firstMerged == "" { 530 | firstMerged = pr 531 | fmt.Println("------- ^^^^ un-considered candidates") 532 | } 533 | } 534 | } else { 535 | gordon.Fatalf("Usage: compare [branch] [branch]") 536 | } 537 | return nil 538 | } 539 | 540 | func before(c *cli.Context) error { 541 | client := gh.NewClient() 542 | 543 | // set up the git remote to be used 544 | org, name, err := gordon.GetRemoteUrl(c.String("remote")) 545 | if err != nil { 546 | return fmt.Errorf("The current directory is not a valid git repository (%s).\n", err) 547 | } 548 | t, err := gordon.NewMaintainerManager(client, org, name) 549 | if err != nil { 550 | return err 551 | } 552 | m = t 553 | 554 | // Set verbosity 555 | gordon.VerboseOutput = c.Bool("verbose") 556 | 557 | return nil 558 | } 559 | 560 | func main() { 561 | 562 | app := cli.NewApp() 563 | 564 | app.Name = "pulls" 565 | app.Usage = "Manage github pull requests for project maintainers" 566 | app.Version = gordon.Version 567 | 568 | os.MkdirAll(templatePath, 0755) 569 | 570 | app.Before = before 571 | loadCommands(app) 572 | 573 | err := app.Run(os.Args) 574 | if err != nil { 575 | gordon.Fatalf(err.Error()) 576 | } 577 | } 578 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/docker/gordon 2 | 3 | go 1.14 4 | 5 | require ( 6 | github.com/aybabtme/color v0.0.0-20140713052517-28ad4cc941d6 7 | github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 // indirect 8 | github.com/cpuguy83/go-md2man/v2 v2.0.0 // indirect 9 | github.com/creack/pty v1.1.11 // indirect 10 | github.com/crosbymichael/octokat v0.0.0-20160826194511-076a32289ed5 11 | github.com/fkautz/codereview v0.0.0-20180503210335-2797383d4e56 12 | github.com/google/go-cmp v0.4.1 // indirect 13 | github.com/kr/pretty v0.2.0 // indirect 14 | github.com/kr/text v0.2.0 // indirect 15 | github.com/moby/term v0.0.0-20200611042045-63b9a826fb74 16 | github.com/urfave/cli v1.22.4 17 | golang.org/x/sys v0.0.0-20200610111108-226ff32320da // indirect 18 | ) 19 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78 h1:w+iIsaOQNcT7OZ575w+acHgRric5iCyQh+xv+KJ4HB8= 2 | github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78/go.mod h1:LmzpDX56iTiv29bbRTIsUNlaFfuhWRQBWjQdVyAevI8= 3 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 4 | github.com/aybabtme/color v0.0.0-20140713052517-28ad4cc941d6 h1:k5FebMq+CuzGrf3LBwO3JbwCk5tX+PJQ5L7tR0MQT38= 5 | github.com/aybabtme/color v0.0.0-20140713052517-28ad4cc941d6/go.mod h1:k6bCbg1gudUfWem/VvfWXsk5Qqag/NDXzEHu0mNcsPM= 6 | github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 h1:DDGfHa7BWjL4YnC6+E63dPcxHo2sUxDIu8g3QgEJdRY= 7 | github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4= 8 | github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d h1:U+s90UTSYgptZMwQh2aRr3LuazLJIa+Pg3Kc1ylSYVY= 9 | github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= 10 | github.com/cpuguy83/go-md2man/v2 v2.0.0 h1:EoUDS0afbrsXAZ9YQ9jdu/mZ2sXgT1/2yyNng4PGlyM= 11 | github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= 12 | github.com/creack/pty v1.1.9 h1:uDmaGzcdjhF4i/plgjmEsriH11Y0o7RKapEf/LDaM3w= 13 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 14 | github.com/creack/pty v1.1.11 h1:07n33Z8lZxZ2qwegKbObQohDhXDQxiMMz1NOUGYlesw= 15 | github.com/creack/pty v1.1.11/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 16 | github.com/crosbymichael/octokat v0.0.0-20160826194511-076a32289ed5 h1:bSQiq0fa9sBvnQOFfbAqhXeviZBPe5wLt3Za8Lc6BVE= 17 | github.com/crosbymichael/octokat v0.0.0-20160826194511-076a32289ed5/go.mod h1:iUcho7q/ROQXsT+t/dR0NGhCZPu1u32DyUF0ytzL4JY= 18 | github.com/fkautz/codereview v0.0.0-20180503210335-2797383d4e56 h1:xjvs+N+3fNAECP8azHO+QOa1wDG87eTAv7EnpZosmbo= 19 | github.com/fkautz/codereview v0.0.0-20180503210335-2797383d4e56/go.mod h1:+h7d69DACAqPassRNAyDntbO38G1MRWQbKa88H2iSGc= 20 | github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 21 | github.com/google/go-cmp v0.4.0 h1:xsAVV57WRhGj6kEIi8ReJzQlHHqcBYCElAvkovg3B/4= 22 | github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 23 | github.com/google/go-cmp v0.4.1 h1:/exdXoGamhu5ONeUJH0deniYLWYvQwW66yvlfiiKTu0= 24 | github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 25 | github.com/kr/pretty v0.2.0 h1:s5hAObm+yFO5uHYt5dYjxi2rXrsnmRpJx4OYvIWUaQs= 26 | github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= 27 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 28 | github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= 29 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 30 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 31 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 32 | github.com/moby/term v0.0.0-20200611042045-63b9a826fb74 h1:kvRIeqJNICemq2UFLx8q/Pj+1IRNZS0XPTaMFkuNsvg= 33 | github.com/moby/term v0.0.0-20200611042045-63b9a826fb74/go.mod h1:pJ0Ot5YGdTcMdxnPMyGCfAr6fKXe0g9cDlz16MuFEBE= 34 | github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 35 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 36 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 37 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 38 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 39 | github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q= 40 | github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 41 | github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= 42 | github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= 43 | github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= 44 | github.com/urfave/cli v1.22.4 h1:u7tSpNPPswAFymm8IehJhy4uJMlUuU/GmqSkvJ1InXA= 45 | github.com/urfave/cli v1.22.4/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= 46 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 47 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 48 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 49 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 50 | golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd h1:xhmwyvizuTgC2qz7ZlMluP20uW+C3Rm0FD/WLDX8884= 51 | golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 52 | golang.org/x/sys v0.0.0-20200610111108-226ff32320da h1:bGb80FudwxpeucJUjPYJXuJ8Hk91vNtfvrymzwiei38= 53 | golang.org/x/sys v0.0.0-20200610111108-226ff32320da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 54 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 55 | golang.org/x/tools v0.0.0-20190624222133-a101b041ded4/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= 56 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= 57 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 58 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 59 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 60 | gotest.tools/v3 v3.0.2 h1:kG1BFyqVHuQoVQiR1bWGnfz/fmHvvuiSPIV7rvl360E= 61 | gotest.tools/v3 v3.0.2/go.mod h1:3SzNCllyD9/Y+b5r9JIKQ474KzkZyqLqEfYqMsX94Bk= 62 | -------------------------------------------------------------------------------- /pkg/filters/filters.go: -------------------------------------------------------------------------------- 1 | package filters 2 | 3 | import ( 4 | "fmt" 5 | "github.com/docker/gordon/pkg/gordon" 6 | "io/ioutil" 7 | "net/http" 8 | "sort" 9 | "strconv" 10 | "strings" 11 | "time" 12 | 13 | "github.com/urfave/cli" 14 | gh "github.com/crosbymichael/octokat" 15 | ) 16 | 17 | func FilterPullRequests(c *cli.Context, prs []*gh.PullRequest) ([]*gh.PullRequest, error) { 18 | var ( 19 | yesterday = time.Now().Add(-24 * time.Hour) 20 | out = filteredPullRequests{} //[]*gh.PullRequest{} 21 | email, err = gordon.GetMaintainerManagerEmail() 22 | chPrs = make(chan *gh.PullRequest) 23 | ) 24 | if err != nil { 25 | return nil, err 26 | } 27 | 28 | for _, pr := range prs { 29 | go func(pr *gh.PullRequest) { 30 | if c.Bool("new") && !pr.CreatedAt.After(yesterday) { 31 | chPrs <- nil 32 | return 33 | } 34 | 35 | if user := c.String("user"); user != "" { 36 | if pr.User.Login != user { 37 | chPrs <- nil 38 | return 39 | } 40 | } 41 | 42 | if c.Bool("cleanup") { 43 | if !strings.HasPrefix(strings.ToLower(pr.Title), "cleanup") { 44 | chPrs <- nil 45 | return 46 | } 47 | } 48 | 49 | maintainer := c.String("maintainer") 50 | if maintainer == "" && c.Bool("mine") { 51 | maintainer = email 52 | } 53 | dir := c.String("dir") 54 | extension := c.String("extension") 55 | 56 | var diff []byte 57 | 58 | if maintainer != "" || dir != "" || extension != "" { 59 | diffResp, err := http.Get(pr.DiffURL) 60 | if err != nil { 61 | chPrs <- nil 62 | return 63 | } 64 | 65 | diff, err = ioutil.ReadAll(diffResp.Body) 66 | if err != nil { 67 | chPrs <- nil 68 | return 69 | } 70 | 71 | diffResp.Body.Close() 72 | } 73 | 74 | if dir != "" { 75 | dirs, err := gordon.GetDirsForPR(diff, dir) 76 | if err != nil { 77 | chPrs <- nil 78 | return 79 | } 80 | 81 | if len(dirs) == 0 { 82 | chPrs <- nil 83 | return 84 | } 85 | } 86 | 87 | if extension != "" { 88 | files, err := gordon.GetFileExtensionsForPR(diff, extension) 89 | if err != nil { 90 | chPrs <- nil 91 | return 92 | } 93 | 94 | if len(files) == 0 { 95 | chPrs <- nil 96 | return 97 | } 98 | } 99 | 100 | if maintainer != "" { 101 | var found bool 102 | reviewers, err := gordon.GetReviewersForPR(diff, true) 103 | if err != nil { 104 | chPrs <- nil 105 | return 106 | } 107 | for file := range reviewers { 108 | for _, reviewer := range reviewers[file] { 109 | if reviewer == maintainer { 110 | found = true 111 | } 112 | } 113 | } 114 | if !found { 115 | chPrs <- nil 116 | return 117 | } 118 | 119 | } 120 | 121 | if c.Bool("unassigned") && pr.Assignee != nil { 122 | chPrs <- nil 123 | return 124 | } else if assigned := c.String("assigned"); assigned != "" && (pr.Assignee == nil || pr.Assignee.Login != assigned) { 125 | chPrs <- nil 126 | return 127 | } 128 | 129 | if c.Bool("lgtm") { 130 | pr.ReviewComments = 0 131 | maintainersOccurrence := map[string]bool{} 132 | for _, comment := range pr.CommentsBody { 133 | // We should check it this LGTM is by a user in 134 | // the maintainers file 135 | userName := comment.User.Login 136 | if strings.Contains(comment.Body, "LGTM") && !maintainersOccurrence[userName] { 137 | maintainersOccurrence[userName] = true 138 | pr.ReviewComments += 1 139 | } 140 | } 141 | } 142 | 143 | if c.Bool("no-merge") && *pr.Mergeable { 144 | chPrs <- nil 145 | return 146 | } 147 | chPrs <- pr 148 | }(pr) 149 | } 150 | for i := 0; i < len(prs); i++ { 151 | if pr := <-chPrs; pr != nil { 152 | out = append(out, pr) 153 | } 154 | } 155 | sort.Sort(out) 156 | return out, nil 157 | } 158 | 159 | type filteredPullRequests []*gh.PullRequest 160 | 161 | func (r filteredPullRequests) Len() int { return len(r) } 162 | func (r filteredPullRequests) Swap(i, j int) { r[i], r[j] = r[j], r[i] } 163 | func (r filteredPullRequests) Less(i, j int) bool { 164 | return r[j].UpdatedAt.After(r[i].UpdatedAt) 165 | } 166 | 167 | func FilterIssues(c *cli.Context, issues []*gh.Issue) ([]*gh.Issue, error) { 168 | var ( 169 | yesterday = time.Now().Add(-24 * time.Hour) 170 | out = []*gh.Issue{} 171 | client = gh.NewClient() 172 | org, name, err = gordon.GetRemoteUrl(c.String("remote")) 173 | ) 174 | if err != nil { 175 | return nil, err 176 | } 177 | t, err := gordon.NewMaintainerManager(client, org, name) 178 | if err != nil { 179 | return nil, err 180 | } 181 | 182 | for _, issue := range issues { 183 | fmt.Printf(".") 184 | 185 | if c.Bool("new") && !issue.CreatedAt.After(yesterday) { 186 | continue 187 | } 188 | 189 | if milestone := c.String("milestone"); milestone != "" && issue.Milestone.Title != milestone { 190 | continue 191 | } 192 | 193 | if numVotes := c.Int("votes"); numVotes > 0 { 194 | comments, err := t.GetComments(strconv.Itoa(issue.Number)) 195 | if err != nil { 196 | return nil, err 197 | } 198 | issue.Comments = 0 199 | for _, comment := range comments { 200 | if strings.Contains(comment.Body, "+1") { 201 | issue.Comments += 1 202 | } 203 | } 204 | if issue.Comments < numVotes { 205 | continue 206 | } 207 | } 208 | 209 | if c.Bool("proposals") && !strings.HasPrefix(issue.Title, "Proposal") { 210 | continue 211 | } 212 | 213 | out = append(out, issue) 214 | } 215 | return out, nil 216 | 217 | } 218 | -------------------------------------------------------------------------------- /pkg/gordon/color.go: -------------------------------------------------------------------------------- 1 | package gordon 2 | 3 | import ( 4 | "github.com/aybabtme/color/brush" 5 | "github.com/moby/term" 6 | ) 7 | 8 | var Colorize bool 9 | 10 | func init() { 11 | if term.IsTerminal(1) { 12 | Colorize = true 13 | } 14 | } 15 | 16 | func Green(s string) string { 17 | if Colorize { 18 | return brush.Green(s).String() 19 | } 20 | return s 21 | } 22 | 23 | func Red(s string) string { 24 | if Colorize { 25 | return brush.Red(s).String() 26 | } 27 | return s 28 | } 29 | 30 | func DarkRed(s string) string { 31 | if Colorize { 32 | return brush.DarkRed(s).String() 33 | } 34 | return s 35 | } 36 | 37 | func DarkYellow(s string) string { 38 | if Colorize { 39 | return brush.DarkYellow(s).String() 40 | } 41 | return s 42 | } 43 | 44 | func Yellow(s string) string { 45 | if Colorize { 46 | return brush.Yellow(s).String() 47 | } 48 | return s 49 | } 50 | -------------------------------------------------------------------------------- /pkg/gordon/display.go: -------------------------------------------------------------------------------- 1 | package gordon 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "fmt" 7 | "io" 8 | "os" 9 | "sort" 10 | "strconv" 11 | "strings" 12 | "text/tabwriter" 13 | "time" 14 | 15 | "github.com/urfave/cli" 16 | gh "github.com/crosbymichael/octokat" 17 | ) 18 | 19 | const ( 20 | defaultTimeFormat = time.RFC822 21 | truncSize = 80 22 | ) 23 | 24 | func newTabwriter() *tabwriter.Writer { 25 | return tabwriter.NewWriter(os.Stdout, 8, 1, 3, ' ', 0) 26 | } 27 | 28 | func truncate(s string) string { 29 | if len(s) > truncSize { 30 | s = s[:truncSize] + "..." 31 | } 32 | return s 33 | } 34 | 35 | func DisplayPullRequests(c *cli.Context, pulls []*gh.PullRequest, notrunc bool) { 36 | w := newTabwriter() 37 | fmt.Fprintf(w, "NUMBER\tSHA\tLAST UPDATED\tCONTRIBUTOR\tASSIGNEE\tTITLE") 38 | if c.Bool("lgtm") { 39 | fmt.Fprintf(w, "\tLGTM") 40 | } 41 | fmt.Fprintf(w, "\n") 42 | for _, p := range pulls { 43 | if !notrunc { 44 | p.Title = truncate(p.Title) 45 | } 46 | var assignee string 47 | if p.Assignee != nil { 48 | assignee = p.Assignee.Login 49 | } 50 | fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\t%s", p.Number, p.Head.Sha[:8], HumanDuration(time.Since(p.UpdatedAt)), p.User.Login, assignee, p.Title) 51 | if c.Bool("lgtm") { 52 | lgtm := strconv.Itoa(p.ReviewComments) 53 | if p.ReviewComments >= 2 { 54 | lgtm = Green(lgtm) 55 | } else if p.ReviewComments == 0 { 56 | lgtm = DarkRed(lgtm) 57 | } else { 58 | lgtm = DarkYellow(lgtm) 59 | } 60 | fmt.Fprintf(w, "\t%s", lgtm) 61 | } 62 | fmt.Fprintf(w, "\n") 63 | } 64 | 65 | if err := w.Flush(); err != nil { 66 | fmt.Fprintf(os.Stderr, "%s", err) 67 | } 68 | } 69 | 70 | func DisplayReviewers(c *cli.Context, reviewers map[string][]string) { 71 | w := newTabwriter() 72 | fmt.Fprintf(w, "FILE\tREVIEWERS") 73 | fmt.Fprintf(w, "\n") 74 | for file, fileReviewers := range reviewers { 75 | var usernames bytes.Buffer 76 | for _, reviewer := range fileReviewers { 77 | usernames.WriteString(reviewer) 78 | usernames.WriteString(", ") 79 | } 80 | usernames.Truncate(usernames.Len() - 2) 81 | fmt.Fprintf(w, "%s\t%s\n", file, usernames.String()) 82 | } 83 | if err := w.Flush(); err != nil { 84 | fmt.Fprintf(os.Stderr, "%s", err) 85 | } 86 | } 87 | 88 | func DisplayContributors(c *cli.Context, contributors []*gh.Contributor) { 89 | var ( 90 | w = newTabwriter() 91 | contributorsStats []ContributorStats 92 | ) 93 | 94 | for _, contrib := range contributors { 95 | contribStats := ContributorStats{} 96 | contribStats.Name = contrib.Author.Login 97 | for _, week := range contrib.Weeks { 98 | contribStats.Additions += week.Additions 99 | contribStats.Deletions += week.Deletions 100 | contribStats.Commits += week.Commits 101 | } 102 | contributorsStats = append(contributorsStats, []ContributorStats{contribStats}...) 103 | } 104 | if c.Bool("additions") { 105 | sort.Sort(ByAdditions(contributorsStats)) 106 | } else if c.Bool("deletions") { 107 | sort.Sort(ByDeletions(contributorsStats)) 108 | } else if c.Bool("commits") { 109 | sort.Sort(ByCommits(contributorsStats)) 110 | } else { 111 | // Sort by default by Commits 112 | sort.Sort(ByCommits(contributorsStats)) 113 | } 114 | topN := c.Int("top") 115 | fmt.Fprintf(w, "CONTRIBUTOR\tADDITIONS\tDELETIONS\tCOMMITS") 116 | fmt.Fprintf(w, "\n") 117 | for i := 0; i < len(contributorsStats) && i < topN; i++ { 118 | fmt.Fprintf(w, "%s\t%d\t%d\t%d", contributorsStats[i].Name, 119 | contributorsStats[i].Additions, 120 | contributorsStats[i].Deletions, 121 | contributorsStats[i].Commits) 122 | fmt.Fprintf(w, "\n") 123 | } 124 | 125 | if err := w.Flush(); err != nil { 126 | fmt.Fprintf(os.Stderr, "%s", err) 127 | } 128 | } 129 | 130 | func DisplayPullRequest(pr *gh.PullRequest, status gh.CombinedStatus) { 131 | fmt.Fprint(os.Stdout, fmt.Sprintf("Pull Request from: %s", Green("@"+pr.User.Login)), "\n") 132 | fmt.Printf("No: %d\nSha: %s\nTitle: %s\n", pr.Number, pr.Head.Sha, pr.Title) 133 | 134 | if pr.Merged { 135 | fmt.Fprintf(os.Stdout, "\nMerged by: %s\nMerged at: %s\nMerge Commit: %s\n\n", Yellow("@"+pr.MergedBy.Login), Yellow(pr.MergedAt.Format(time.RubyDate)), Yellow(pr.MergeCommitSha)) 136 | } else { 137 | if pr.Mergeable != nil { 138 | if pr.Mergeable != nil && *pr.Mergeable { 139 | m := fmt.Sprintf("%t", *pr.Mergeable) 140 | fmt.Fprintf(os.Stdout, "Mergeable: %s", Green(m)) 141 | } else { 142 | m := "false" 143 | fmt.Fprintf(os.Stdout, "Mergeable: %s", Red(m)) 144 | } 145 | } else { 146 | m := "unknown" 147 | fmt.Fprintf(os.Stdout, "Mergeable: %s", Yellow(m)) 148 | } 149 | } 150 | fmt.Fprint(os.Stdout, "\n") 151 | 152 | var buildStatus string 153 | switch status.State { 154 | case "pending": 155 | buildStatus = Yellow("none") 156 | case "success": 157 | buildStatus = Green("success") 158 | case "error": 159 | buildStatus = Red("error") 160 | case "failure": 161 | buildStatus = Red("failure") 162 | default: 163 | buildStatus = Red("unknown") 164 | } 165 | fmt.Fprintln(os.Stdout, "Build Status:", buildStatus) 166 | 167 | states := make(map[string]gh.Status) 168 | if status.State != "success" { 169 | for _, curState := range status.Statuses { 170 | if _, ok := states[curState.Context]; ok == false { 171 | states[curState.Context] = curState 172 | } 173 | } 174 | } 175 | 176 | var contexts []string 177 | for _, v := range states { 178 | contexts = append(contexts, v.Context) 179 | } 180 | sort.Strings(contexts) 181 | for _, v := range contexts { 182 | statusString := fmt.Sprintln("\t" + v + ": " + states[v].State + " " + states[v].TargetURL) 183 | switch states[v].State { 184 | case "pending": 185 | buildStatus = Yellow(statusString) 186 | case "success": 187 | buildStatus = Green(statusString) 188 | default: 189 | buildStatus = Red(statusString) 190 | } 191 | fmt.Fprint(os.Stdout, buildStatus) 192 | } 193 | 194 | lines := strings.Split(pr.Body, "\n") 195 | for i, l := range lines { 196 | lines[i] = "\t" + l 197 | } 198 | fmt.Printf("Description:\n\n%s\n\n", strings.Join(lines, "\n")) 199 | fmt.Printf("\n\n") 200 | } 201 | 202 | func DisplayComments(comments []gh.Comment) { 203 | fmt.Fprintln(os.Stdout, "Comments:") 204 | for _, c := range comments { 205 | fmt.Printf("<%s\n@%s %s\n%s\n%s>", strings.Repeat("=", 79), Red(c.User.Login), c.CreatedAt.Format(defaultTimeFormat), strings.Replace(c.Body, "LGTM", fmt.Sprintf("%s", Green("LGTM")), -1), strings.Repeat("=", 79)) 206 | fmt.Fprint(os.Stdout, "\n\n") 207 | } 208 | } 209 | 210 | func DisplayCommentAdded(cmt gh.Comment) { 211 | fmt.Printf("Comment added at %s\n", cmt.CreatedAt.Format(defaultTimeFormat)) 212 | } 213 | 214 | func printIssue(c *cli.Context, w *tabwriter.Writer, number int, updatedAt time.Time, login, milestone, title string, comments int) { 215 | fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s", number, HumanDuration(time.Since(updatedAt)), login, milestone, title) 216 | if c.Int("votes") > 0 { 217 | votes := strconv.Itoa(comments) 218 | if comments >= 2 { 219 | votes = Green(votes) 220 | } 221 | fmt.Fprintf(w, "\t%s", votes) 222 | } 223 | fmt.Fprintf(w, "\n") 224 | } 225 | 226 | // Display Issues prints `issues` to standard output in a human-friendly tabulated format. 227 | func DisplayIssues(c *cli.Context, v interface{}, notrunc bool) { 228 | w := newTabwriter() 229 | fmt.Fprintf(w, "NUMBER\tLAST UPDATED\tASSIGNEE\tMILESTONE\nTITLE") 230 | if c.Int("votes") > 0 { 231 | fmt.Fprintf(w, "\tVOTES") 232 | } 233 | fmt.Fprintf(w, "\n") 234 | 235 | switch issues := v.(type) { 236 | case []*gh.Issue: 237 | for _, p := range issues { 238 | printIssue(c, w, p.Number, p.UpdatedAt, p.Assignee.Login, p.Milestone.Title, p.Title, p.Comments) 239 | } 240 | case []*gh.SearchItem: 241 | for _, p := range issues { 242 | printIssue(c, w, p.Number, p.UpdatedAt, p.Assignee.Login, p.Milestone.Title, p.Title, p.Comments) 243 | } 244 | } 245 | if err := w.Flush(); err != nil { 246 | fmt.Fprintf(os.Stderr, "%s", err) 247 | } 248 | } 249 | 250 | func DisplayIssue(issue *gh.Issue, comments []gh.Comment) { 251 | fmt.Fprint(os.Stdout, Green("Issue:"), "\n") 252 | fmt.Printf("No: %d\nTitle: %s\n\n", issue.Number, issue.Title) 253 | 254 | lines := strings.Split(issue.Body, "\n") 255 | for i, l := range lines { 256 | lines[i] = "\t" + l 257 | } 258 | fmt.Printf("Description:\n\n%s\n\n", strings.Join(lines, "\n")) 259 | fmt.Printf("\n\n") 260 | 261 | DisplayComments(comments) 262 | } 263 | 264 | // HumanDuration returns a human-readable approximation of a duration 265 | // This function is taken from the Docker project, and slightly modified 266 | // to cap units at days. 267 | // (eg. "About a minute", "4 hours ago", etc.) 268 | // (c) 2013 Docker, inc. and the Docker authors (http://docker.io) 269 | func HumanDuration(d time.Duration) string { 270 | if seconds := int(d.Seconds()); seconds < 1 { 271 | return "Less than a second" 272 | } else if seconds < 60 { 273 | return fmt.Sprintf("%d seconds", seconds) 274 | } else if minutes := int(d.Minutes()); minutes == 1 { 275 | return "About a minute" 276 | } else if minutes < 60 { 277 | return fmt.Sprintf("%d minutes", minutes) 278 | } else if hours := int(d.Hours()); hours == 1 { 279 | return "About an hour" 280 | } else if hours < 48 { 281 | return fmt.Sprintf("%d hours", hours) 282 | } 283 | return fmt.Sprintf("%d days", int(d.Hours()/24)) 284 | } 285 | 286 | func DisplayPatch(r io.Reader) error { 287 | s := bufio.NewScanner(r) 288 | for s.Scan() { 289 | if err := s.Err(); err != nil { 290 | return err 291 | } 292 | t := s.Text() 293 | 294 | switch t[0] { 295 | case '-': 296 | fmt.Fprintln(os.Stdout, Red(t)) 297 | case '+': 298 | fmt.Fprintln(os.Stdout, Green(t)) 299 | default: 300 | fmt.Fprintln(os.Stdout, t) 301 | } 302 | } 303 | return nil 304 | } 305 | -------------------------------------------------------------------------------- /pkg/gordon/github.go: -------------------------------------------------------------------------------- 1 | package gordon 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "os" 7 | "path" 8 | "regexp" 9 | "strconv" 10 | "strings" 11 | "sync" 12 | 13 | gh "github.com/crosbymichael/octokat" 14 | ) 15 | 16 | // Top level type that manages a repository 17 | type MaintainerManager struct { 18 | repo gh.Repo 19 | client *gh.Client 20 | email string 21 | username string 22 | originPath string 23 | } 24 | 25 | type Config struct { 26 | Token string 27 | UserName string 28 | } 29 | 30 | var ( 31 | belongsToOthers = false 32 | configPath = path.Join(os.Getenv("HOME"), ".maintainercfg") 33 | ) 34 | 35 | func LoadConfig() (*Config, error) { 36 | var config Config 37 | f, err := os.Open(configPath) 38 | if err != nil { 39 | if !os.IsNotExist(err) { 40 | return &config, err 41 | } 42 | } else { 43 | defer f.Close() 44 | 45 | dec := json.NewDecoder(f) 46 | if err := dec.Decode(&config); err != nil { 47 | return &config, err 48 | } 49 | } 50 | return &config, err 51 | } 52 | 53 | func SaveConfig(config Config) error { 54 | f, err := os.OpenFile(configPath, os.O_CREATE|os.O_RDWR, 0600) 55 | if err != nil { 56 | return err 57 | } 58 | defer f.Close() 59 | 60 | enc := json.NewEncoder(f) 61 | if err := enc.Encode(config); err != nil { 62 | return err 63 | } 64 | return nil 65 | } 66 | 67 | func getRepoPath(pth, org string) string { 68 | flag := false 69 | i := 0 70 | repoPath := path.Dir("/") 71 | for _, dir := range strings.Split(pth, "/") { 72 | if strings.EqualFold(dir, org) { 73 | flag = true 74 | } 75 | if flag { 76 | if i >= 2 { 77 | repoPath = path.Join(repoPath, dir) 78 | } 79 | i++ 80 | } 81 | } 82 | return repoPath 83 | } 84 | 85 | func getOriginPath(repo string) (string, error) { 86 | currentPath, err := os.Getwd() 87 | if err != nil { 88 | return "", err 89 | } 90 | 91 | originPath := path.Dir("/") 92 | for _, dir := range strings.Split(currentPath, "/") { 93 | originPath = path.Join(originPath, dir) 94 | if strings.EqualFold(dir, repo) { 95 | break 96 | } 97 | } 98 | return originPath, err 99 | } 100 | 101 | func NewMaintainerManager(client *gh.Client, org, repo string) (*MaintainerManager, error) { 102 | config, err := LoadConfig() 103 | if err == nil { 104 | client.WithToken(config.Token) 105 | } 106 | originPath, err := getOriginPath(repo) 107 | if err != nil { 108 | return nil, fmt.Errorf("getoriginpath: %v", err) 109 | } 110 | email, err := GetMaintainerManagerEmail() 111 | if err != nil { 112 | return nil, fmt.Errorf("getemail: %v", err) 113 | } 114 | return &MaintainerManager{ 115 | repo: gh.Repo{Name: repo, UserName: org}, 116 | client: client, 117 | email: email, 118 | originPath: originPath, 119 | username: config.UserName, 120 | }, nil 121 | } 122 | 123 | func (m *MaintainerManager) Repository() (*gh.Repository, error) { 124 | return m.client.Repository(m.repo, nil) 125 | } 126 | 127 | func (m *MaintainerManager) worker(prepr <-chan *gh.PullRequest, pospr chan<- *gh.PullRequest, wg *sync.WaitGroup, needFullPr, needComments bool) { 128 | var err error 129 | defer wg.Done() 130 | 131 | for p := range prepr { 132 | if needFullPr { 133 | p, err = m.GetPullRequest(strconv.Itoa(p.Number)) 134 | if err != nil { 135 | return 136 | } 137 | } 138 | if needComments { 139 | p.CommentsBody, err = m.GetComments(strconv.Itoa(p.Number)) 140 | if err != nil { 141 | return 142 | } 143 | } 144 | pospr <- p 145 | fmt.Printf(".") 146 | } 147 | } 148 | 149 | func (m *MaintainerManager) GetFullPullRequests(prs []*gh.PullRequest, needFullPr, needComments bool) []*gh.PullRequest { 150 | var ( 151 | producer = make(chan *gh.PullRequest, NumWorkers) 152 | consumer = make(chan *gh.PullRequest, NumWorkers) 153 | wg = &sync.WaitGroup{} 154 | consumerGroup = &sync.WaitGroup{} 155 | filteredPrs = []*gh.PullRequest{} 156 | ) 157 | 158 | // take the finished results and put them into the list 159 | consumerGroup.Add(1) 160 | go func() { 161 | defer consumerGroup.Done() 162 | 163 | for p := range consumer { 164 | filteredPrs = append(filteredPrs, p) 165 | } 166 | }() 167 | 168 | for i := 0; i < NumWorkers; i++ { 169 | wg.Add(1) 170 | go m.worker(producer, consumer, wg, needFullPr, needComments) 171 | } 172 | 173 | // add all jobs 174 | for _, p := range prs { 175 | producer <- p 176 | } 177 | // we are done sending jobs so close the channel 178 | close(producer) 179 | 180 | wg.Wait() 181 | 182 | close(consumer) 183 | // wait for the consumer to finish adding all the results to the list 184 | consumerGroup.Wait() 185 | 186 | return filteredPrs 187 | } 188 | 189 | // Return all pull requests 190 | func (m *MaintainerManager) GetPullRequests(state, sort string) ([]*gh.PullRequest, error) { 191 | o := &gh.Options{} 192 | o.QueryParams = map[string]string{ 193 | "sort": sort, 194 | "direction": "asc", 195 | "state": state, 196 | "per_page": "100", 197 | } 198 | prevSize := -1 199 | page := 1 200 | allPRs := []*gh.PullRequest{} 201 | for len(allPRs) != prevSize { 202 | o.QueryParams["page"] = strconv.Itoa(page) 203 | if prs, err := m.client.PullRequests(m.repo, o); err != nil { 204 | return nil, err 205 | } else { 206 | prevSize = len(allPRs) 207 | allPRs = append(allPRs, prs...) 208 | page += 1 209 | } 210 | fmt.Printf(".") 211 | } 212 | return allPRs, nil 213 | } 214 | 215 | // Return all pull request Files 216 | func (m *MaintainerManager) GetPullRequestFiles(number string) ([]*gh.PullRequestFile, error) { 217 | o := &gh.Options{} 218 | o.QueryParams = map[string]string{} 219 | allPrFiles := []*gh.PullRequestFile{} 220 | 221 | if prfs, err := m.client.PullRequestFiles(m.repo, number, o); err != nil { 222 | return nil, err 223 | } else { 224 | allPrFiles = append(allPrFiles, prfs...) 225 | 226 | } 227 | return allPrFiles, nil 228 | } 229 | 230 | func (m *MaintainerManager) GetFirstPullRequest(state, sortBy string) (*gh.PullRequest, error) { 231 | o := &gh.Options{} 232 | o.QueryParams = map[string]string{ 233 | "state": state, 234 | "per_page": "1", 235 | "page": "1", 236 | "sort": sortBy, 237 | "direction": "asc", 238 | } 239 | prs, err := m.client.PullRequests(m.repo, o) 240 | if err != nil { 241 | return nil, err 242 | } 243 | if len(prs) == 0 { 244 | return nil, fmt.Errorf("No matching pull request") 245 | } 246 | return prs[0], nil 247 | } 248 | 249 | // Return a single pull request 250 | func (m *MaintainerManager) GetPullRequest(number string) (*gh.PullRequest, error) { 251 | return m.client.PullRequest(m.repo, number, nil) 252 | } 253 | 254 | // Return a single issue 255 | // Return issue's comments if requested 256 | func (m *MaintainerManager) GetIssue(number string, comments bool) (*gh.Issue, []gh.Comment, error) { 257 | var c []gh.Comment 258 | num, err := strconv.Atoi(number) 259 | if err != nil { 260 | return nil, nil, err 261 | } 262 | issue, err := m.client.Issue(m.repo, num, nil) 263 | if err != nil { 264 | return nil, nil, err 265 | } 266 | if comments { 267 | c, err = m.GetComments(number) 268 | if err != nil { 269 | return nil, nil, err 270 | } 271 | } 272 | return issue, c, nil 273 | } 274 | 275 | // Return all issue found 276 | func (m *MaintainerManager) GetIssuesFound(query string) ([]*gh.SearchItem, error) { 277 | o := &gh.Options{} 278 | o.QueryParams = map[string]string{ 279 | "sort": "updated", 280 | "order": "asc", 281 | "per_page": "100", 282 | } 283 | prevSize := -1 284 | page := 1 285 | issuesFound := []*gh.SearchItem{} 286 | for len(issuesFound) != prevSize { 287 | o.QueryParams["page"] = strconv.Itoa(page) 288 | if issues, err := m.client.SearchIssues(query, o); err != nil { 289 | return nil, err 290 | } else { 291 | prevSize = len(issuesFound) 292 | issuesFound = append(issuesFound, issues...) 293 | page += 1 294 | } 295 | fmt.Printf(".") 296 | } 297 | return issuesFound, nil 298 | } 299 | 300 | // Return contributors list 301 | func (m *MaintainerManager) GetContributors() ([]*gh.Contributor, error) { 302 | o := &gh.Options{} 303 | contributors, err := m.client.Contributors(m.repo, o) 304 | if err != nil { 305 | return nil, err 306 | } 307 | 308 | return contributors, nil 309 | } 310 | 311 | // Return all comments for an issue or pull request 312 | func (m *MaintainerManager) GetComments(number string) ([]gh.Comment, error) { 313 | return m.client.Comments(m.repo, number, nil) 314 | } 315 | 316 | // Add a comment to an existing pull request 317 | func (m *MaintainerManager) AddComment(number, comment string) (gh.Comment, error) { 318 | return m.client.AddComment(m.repo, number, comment) 319 | } 320 | 321 | // Merge a pull request 322 | // If no LGTMs are in the comments require force to be true 323 | func (m *MaintainerManager) MergePullRequest(number, comment string, force bool) (gh.Merge, error) { 324 | comments, err := m.GetComments(number) 325 | if err != nil { 326 | return gh.Merge{}, err 327 | } 328 | isApproved := false 329 | for _, c := range comments { 330 | // FIXME: Again should check for LGTM from a maintainer 331 | if strings.Contains(c.Body, "LGTM") { 332 | isApproved = true 333 | break 334 | } 335 | } 336 | if !isApproved && !force { 337 | return gh.Merge{}, fmt.Errorf("Pull request %s has not been approved", number) 338 | } 339 | o := &gh.Options{} 340 | o.Params = map[string]string{ 341 | "commit_message": comment, 342 | } 343 | return m.client.MergePullRequest(m.repo, number, o) 344 | } 345 | 346 | // Checkout the pull request into the working tree of 347 | // the users repository. 348 | // 349 | // It's up to the caller to decide what to do with the checked out 350 | // branch - typically created a named branch with 'checkout -b'. 351 | func (m *MaintainerManager) Checkout(pr *gh.PullRequest) error { 352 | if err := Git("fetch", pr.Head.Repo.CloneURL, pr.Head.Ref); err != nil { 353 | return fmt.Errorf("git fetch: %v", err) 354 | } 355 | if err := Git("checkout", "FETCH_HEAD"); err != nil { 356 | return fmt.Errorf("git checkout: %v", err) 357 | } 358 | return nil 359 | } 360 | 361 | // Get the user information from the authenticated user 362 | func (m *MaintainerManager) GetGithubUser() (*gh.User, error) { 363 | user, err := m.client.User("", nil) 364 | if err != nil { 365 | return nil, err 366 | } 367 | return user, err 368 | } 369 | 370 | // Patch an issue 371 | func (m *MaintainerManager) PatchIssue(number string, issue *gh.Issue) (*gh.Issue, error) { 372 | o := &gh.Options{} 373 | o.Params = map[string]string{ 374 | "title": issue.Title, 375 | "body": issue.Body, 376 | "assignee": issue.Assignee.Login, 377 | } 378 | patchedIssue, err := m.client.PatchIssue(m.repo, number, o) 379 | if err != nil { 380 | return nil, err 381 | } 382 | return patchedIssue, err 383 | } 384 | 385 | func (m *MaintainerManager) CreatePullRequest(base, head, title, body string) (*gh.PullRequest, error) { 386 | return m.client.CreatePullRequest( 387 | m.repo, 388 | &gh.Options{ 389 | Params: map[string]string{ 390 | "title": title, 391 | "head": head, 392 | "base": base, 393 | "body": body, 394 | }, 395 | }, 396 | ) 397 | } 398 | 399 | // Patch a pull request 400 | func (m *MaintainerManager) PatchPullRequest(number string, pr *gh.PullRequest) (*gh.PullRequest, error) { 401 | o := &gh.Options{} 402 | params := map[string]string{ 403 | "title": pr.Title, 404 | "body": pr.Body, 405 | } 406 | if pr.Assignee == nil { 407 | params["assignee"] = "" 408 | } else { 409 | params["assignee"] = pr.Assignee.Login 410 | } 411 | o.Params = params 412 | // octokat doesn't expose PatchPullRequest. Use PatchIssue instead. 413 | _, err := m.client.PatchIssue(m.repo, number, o) 414 | if err != nil { 415 | return nil, err 416 | } 417 | // Simulate the result of the patching 418 | patchedPR := *pr 419 | return &patchedPR, nil 420 | } 421 | 422 | func (m *MaintainerManager) Close(number string) error { 423 | _, err := m.client.PatchIssue( 424 | m.repo, 425 | number, 426 | &gh.Options{Params: map[string]string{"state": "closed"}}, 427 | ) 428 | return err 429 | } 430 | 431 | func (m *MaintainerManager) GetFirstIssue(state, sortBy string) (*gh.Issue, error) { 432 | o := &gh.Options{} 433 | o.QueryParams = map[string]string{ 434 | "state": state, 435 | "per_page": "1", 436 | "page": "1", 437 | "sort": sortBy, 438 | "direction": "asc", 439 | } 440 | issues, err := m.client.Issues(m.repo, o) 441 | if err != nil { 442 | return &gh.Issue{}, err 443 | } 444 | if len(issues) == 0 { 445 | return &gh.Issue{}, fmt.Errorf("No matching issues") 446 | } 447 | return issues[0], nil 448 | } 449 | 450 | // GetIssues queries the GithubAPI for all issues matching the state `state` and the 451 | // assignee `assignee`. 452 | // See http://developer.github.com/v3/issues/#list-issues-for-a-repository 453 | func (m *MaintainerManager) GetIssues(state, assignee string) ([]*gh.Issue, error) { 454 | o := &gh.Options{} 455 | o.QueryParams = map[string]string{ 456 | "sort": "updated", 457 | "direction": "asc", 458 | "state": state, 459 | "per_page": "100", 460 | } 461 | // If assignee == "", don't add it to the params. 462 | // This will show all issues, assigned or not. 463 | if assignee != "" { 464 | o.QueryParams["assignee"] = assignee 465 | } 466 | prevSize := -1 467 | page := 1 468 | all := []*gh.Issue{} 469 | for len(all) != prevSize { 470 | o.QueryParams["page"] = strconv.Itoa(page) 471 | if issues, err := m.client.Issues(m.repo, o); err != nil { 472 | return nil, err 473 | } else { 474 | prevSize = len(all) 475 | all = append(all, issues...) 476 | page += 1 477 | } 478 | fmt.Printf(".") 479 | } 480 | return all, nil 481 | } 482 | 483 | // GenBranchName returns a generated branch name from a human-readable description. 484 | // 485 | // For example this: 486 | // `GenBranchName(" Hey! let's do awesome stuff...")` 487 | // Will return this: 488 | // `"hey_let_s_do_awesome_stuff"` 489 | func GenBranchName(text string) string { 490 | toRemove := regexp.MustCompile("(^[[:^alnum:]]+|[[:^alnum:]]$)") 491 | toUnderscore := regexp.MustCompile("[[:^alnum:]]+") 492 | branchName := strings.ToLower(text) 493 | branchName = toRemove.ReplaceAllString(branchName, "") 494 | branchName = toUnderscore.ReplaceAllString(branchName, "_") 495 | return branchName 496 | } 497 | 498 | // GetStatus queries the GithubAPI for the current build status of a pull request 499 | // See http://developer.github.com/v3/issues/#list-issues-for-a-repository 500 | func (m *MaintainerManager) GetStatus(pr *gh.PullRequest) (gh.CombinedStatus, error) { 501 | o := &gh.Options{} 502 | o.QueryParams = map[string]string{} 503 | return m.client.CombinedStatus(m.repo, pr.Head.Sha, o) 504 | } 505 | -------------------------------------------------------------------------------- /pkg/gordon/maintainers.go: -------------------------------------------------------------------------------- 1 | package gordon 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "io/ioutil" 7 | "os" 8 | "path/filepath" 9 | ) 10 | 11 | const ( 12 | MaintainerFileName = "MAINTAINERS" 13 | NumWorkers = 10 14 | ) 15 | 16 | // GetMaintainersFromRepo returns the maintainers for a repo with the username 17 | // as the key and the file's that they own as a slice in the value 18 | func GetMaintainersFromRepo(repoPath string, withUsername bool) (map[string][]string, error) { 19 | current := make(map[string][]string) 20 | 21 | if err := getMaintainersForDirectory(repoPath, repoPath, current, withUsername); err != nil { 22 | return nil, err 23 | } 24 | return current, nil 25 | } 26 | 27 | func getMaintainersForDirectory(root, dir string, current map[string][]string, withUsername bool) error { 28 | maintainersPerFile, err := getMaintainersFromFile(dir, withUsername) 29 | if err != nil && !os.IsNotExist(err) { 30 | return err 31 | } 32 | 33 | for m, files := range maintainersPerFile { 34 | for _, f := range files { 35 | p, err := filepath.Rel(root, filepath.Join(dir, f)) 36 | if err != nil { 37 | return err 38 | } 39 | current[m] = append(current[m], p) 40 | } 41 | } 42 | 43 | contents, err := ioutil.ReadDir(dir) 44 | if err != nil { 45 | return err 46 | } 47 | 48 | for _, fi := range contents { 49 | if fi.IsDir() && fi.Name() != ".git" { 50 | if err := getMaintainersForDirectory(root, filepath.Join(dir, fi.Name()), current, withUsername); err != nil { 51 | return err 52 | } 53 | } 54 | } 55 | return nil 56 | } 57 | 58 | func getMaintainersFromFile(dir string, withUsername bool) (map[string][]string, error) { 59 | maintainerFile := filepath.Join(dir, MaintainerFileName) 60 | f, err := os.Open(maintainerFile) 61 | if err != nil { 62 | return nil, err 63 | } 64 | defer f.Close() 65 | 66 | var ( 67 | maintainer = make(map[string][]string) 68 | s = bufio.NewScanner(f) 69 | ) 70 | for s.Scan() { 71 | if err := s.Err(); err != nil { 72 | return nil, err 73 | } 74 | t := s.Text() 75 | if t == "" || t[0] == '#' { 76 | continue 77 | } 78 | m := parseMaintainer(t) 79 | if m.Email == "" { 80 | return nil, fmt.Errorf("invalid maintainer file format %s in %s", t, maintainerFile) 81 | } 82 | target := m.Target 83 | if target == "" { 84 | target = "*" 85 | } 86 | maintainer[m.Email] = append(maintainer[m.Email], target) 87 | if withUsername { 88 | maintainer[m.Username] = append(maintainer[m.Username], target) 89 | } 90 | } 91 | return maintainer, nil 92 | } 93 | 94 | // this function basically reverses the maintainers format so that file paths can be looked 95 | // up by path and the maintainers are the value. We have to parse the directories differently 96 | // at first then lookup per path when we actually have the files so that it is much faster 97 | // and cleaner than walking a fill dir tree looking at files and placing them into memeory. 98 | // 99 | // I swear I'm not crazy 100 | func buildFileIndex(maintainers map[string][]string) map[string]map[string]bool { 101 | index := make(map[string]map[string]bool) 102 | 103 | for m, files := range maintainers { 104 | for _, f := range files { 105 | nm, exists := index[f] 106 | if !exists { 107 | nm = make(map[string]bool) 108 | index[f] = nm 109 | } 110 | nm[m] = true 111 | } 112 | } 113 | return index 114 | } 115 | -------------------------------------------------------------------------------- /pkg/gordon/review.go: -------------------------------------------------------------------------------- 1 | package gordon 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "os" 7 | "path" 8 | "path/filepath" 9 | "regexp" 10 | "strings" 11 | 12 | "github.com/fkautz/codereview/patch" 13 | ) 14 | 15 | func GetFileExtensionsForPR(src []byte, ext string) ([]string, error) { 16 | extensions := []string{} 17 | 18 | set, err := patch.Parse(src) 19 | if err != nil { 20 | return nil, err 21 | } 22 | 23 | for _, fileset := range set.File { 24 | if strings.HasSuffix(fileset.Dst, ext) || strings.HasPrefix(fileset.Src, ext) { 25 | extensions = append(extensions, ext) 26 | } 27 | } 28 | 29 | return extensions, nil 30 | } 31 | 32 | func GetDirsForPR(src []byte, dir string) ([]string, error) { 33 | dirs := []string{} 34 | 35 | set, err := patch.Parse(src) 36 | if err != nil { 37 | return nil, err 38 | } 39 | 40 | for _, fileset := range set.File { 41 | if strings.HasPrefix(fileset.Dst, dir) || strings.HasPrefix(fileset.Src, dir) { 42 | dirs = append(dirs, dir) 43 | } 44 | } 45 | 46 | return dirs, nil 47 | } 48 | 49 | func GetReviewersForPR(patch []byte, withUsername bool) (map[string][]string, error) { 50 | toplevel, err := GetTopLevelGitRepo() 51 | if err != nil { 52 | return nil, err 53 | } 54 | maintainers, err := GetMaintainersFromRepo(toplevel, withUsername) 55 | if err != nil { 56 | return nil, err 57 | } 58 | 59 | return ReviewPatch(patch, maintainers) 60 | } 61 | 62 | // ReviewPatch reads a git-formatted patch from `src`, and for each file affected by the patch 63 | // it assign its Maintainers based on the current repository tree directories 64 | // The list of Maintainers are generated when the MaintainerManager object is instantiated. 65 | // 66 | // The result is a map where the keys are the paths of files affected by the patch, 67 | // and the values are the maintainers assigned to review that partiular file. 68 | // 69 | // There is no duplicate checks: the same maintainer may be present in multiple entries 70 | // of the map, or even multiple times in the same entry if the MAINTAINERS file has 71 | // duplicate lines. 72 | func ReviewPatch(input []byte, maintainers map[string][]string) (map[string][]string, error) { 73 | var ( 74 | reviewers = make(map[string][]string) 75 | index = buildFileIndex(maintainers) 76 | ) 77 | 78 | set, err := patch.Parse(input) 79 | if err != nil { 80 | return nil, err 81 | } 82 | mapReviewers := func(rm map[string]bool) []string { 83 | var ( 84 | i int 85 | out = make([]string, len(rm)) 86 | ) 87 | for k := range rm { 88 | out[i] = k 89 | i++ 90 | } 91 | return out 92 | } 93 | 94 | for _, f := range set.File { 95 | for _, originalTarget := range []string{f.Dst, f.Src} { 96 | if originalTarget == "" { 97 | continue 98 | } 99 | target := path.Clean(originalTarget) 100 | if _, exists := reviewers[target]; exists { 101 | continue 102 | } 103 | 104 | var fileMaintainers map[string]bool 105 | fileMaintainers = index[target] 106 | for len(fileMaintainers) == 0 { 107 | target = path.Dir(target) 108 | fileMaintainers = index[target] 109 | } 110 | reviewers[originalTarget] = mapReviewers(fileMaintainers) 111 | } 112 | } 113 | return reviewers, nil 114 | } 115 | 116 | type MaintainerFile map[string][]*Maintainer 117 | 118 | type Maintainer struct { 119 | Username string 120 | FullName string 121 | Email string 122 | Target string 123 | Active bool 124 | Lead bool 125 | Raw string 126 | } 127 | 128 | // Currently not being used 129 | func LoadMaintainerFile(dir string) (MaintainerFile, error) { 130 | src, err := os.Open(path.Join(dir, "MAINTAINERS")) 131 | if err != nil { 132 | return nil, err 133 | } 134 | maintainers := make(MaintainerFile) 135 | scanner := bufio.NewScanner(src) 136 | for scanner.Scan() { 137 | m := parseMaintainer(scanner.Text()) 138 | if m.Username == "" && m.Email == "" && m.FullName == "" { 139 | return nil, fmt.Errorf("Incorrect maintainer format: %s", m.Raw) 140 | } 141 | if _, exists := maintainers[m.Target]; !exists { 142 | maintainers[m.Target] = make([]*Maintainer, 0, 1) 143 | } 144 | maintainers[m.Target] = append(maintainers[m.Target], m) 145 | } 146 | return maintainers, nil 147 | } 148 | 149 | func parseMaintainer(line string) *Maintainer { 150 | const ( 151 | commentIndex = 1 152 | targetIndex = 3 153 | fullnameIndex = 4 154 | emailIndex = 5 155 | usernameIndex = 7 156 | ) 157 | re := regexp.MustCompile("^[ \t]*(#|)((?P[^: ]*) *:|) *(?P[a-zA-Z][^<]*) *<(?P[^>]*)> *(\\(@(?P[^\\)]+)\\)|).*$") 158 | match := re.FindStringSubmatch(line) 159 | return &Maintainer{ 160 | Active: match[commentIndex] == "", 161 | Target: path.Base(path.Clean(match[targetIndex])), 162 | Username: strings.Trim(match[usernameIndex], " \t"), 163 | Email: strings.Trim(match[emailIndex], " \t"), 164 | FullName: strings.Trim(match[fullnameIndex], " \t"), 165 | Raw: line, 166 | } 167 | } 168 | 169 | // Currently not being used 170 | // 171 | // TopMostMaintainerFile moves up the directory tree looking for a MAINTAINERS file, 172 | // parses the top-most file it finds, and returns its contents. 173 | // This is used to find the top-level maintainer of a project for certain 174 | // privileged reviews, such as authorizing changes to a MAINTAINERS file. 175 | func TopMostMaintainerFile(dir string) (MaintainerFile, error) { 176 | if _, err := os.Stat(dir); err != nil { 177 | return nil, err 178 | } 179 | dir, err := filepath.Abs(dir) 180 | if err != nil { 181 | return nil, err 182 | } 183 | if dir == "/" { 184 | return make(MaintainerFile), nil 185 | } 186 | parent, err := TopMostMaintainerFile(path.Dir(dir)) 187 | if err != nil { 188 | // Ignore recursive errors which might be caused by 189 | // permission errors on parts of the filesystem, etc. 190 | parent = make(MaintainerFile) 191 | } 192 | if len(parent) > 0 { 193 | return parent, nil 194 | } 195 | current, err := LoadMaintainerFile(dir) 196 | if os.IsNotExist(err) { 197 | return make(MaintainerFile), nil 198 | } else if err != nil { 199 | return nil, err 200 | } 201 | return current, nil 202 | } 203 | -------------------------------------------------------------------------------- /pkg/gordon/sorter.go: -------------------------------------------------------------------------------- 1 | package gordon 2 | 3 | type ContributorStats struct { 4 | Name string 5 | Additions int 6 | Deletions int 7 | Commits int 8 | } 9 | 10 | type ByAdditions []ContributorStats 11 | type ByDeletions []ContributorStats 12 | type ByCommits []ContributorStats 13 | 14 | func (a ByAdditions) Len() int { return len(a) } 15 | func (a ByAdditions) Swap(i, j int) { a[i], a[j] = a[j], a[i] } 16 | func (a ByAdditions) Less(i, j int) bool { return a[j].Additions < a[i].Additions } 17 | 18 | func (a ByDeletions) Len() int { return len(a) } 19 | func (a ByDeletions) Swap(i, j int) { a[i], a[j] = a[j], a[i] } 20 | func (a ByDeletions) Less(i, j int) bool { return a[j].Deletions < a[i].Deletions } 21 | 22 | func (a ByCommits) Len() int { return len(a) } 23 | func (a ByCommits) Swap(i, j int) { a[i], a[j] = a[j], a[i] } 24 | func (a ByCommits) Less(i, j int) bool { return a[j].Commits < a[i].Commits } 25 | -------------------------------------------------------------------------------- /pkg/gordon/utils.go: -------------------------------------------------------------------------------- 1 | package gordon 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "errors" 7 | "fmt" 8 | "os" 9 | "os/exec" 10 | "strings" 11 | ) 12 | 13 | var ( 14 | VerboseOutput = false 15 | ErrNoUsernameKnown = errors.New("No Username known - you probably need to use the `auth` option to configure your GitHub token") 16 | ) 17 | 18 | type remote struct { 19 | Name string 20 | Url string 21 | } 22 | 23 | func PrintVerboseCommand(cmd *exec.Cmd) { 24 | if VerboseOutput { 25 | fmt.Fprintf(os.Stderr, "executing %q ...\n", strings.Join(cmd.Args, " ")) 26 | } 27 | } 28 | 29 | func Fatalf(format string, args ...interface{}) { 30 | if !strings.HasSuffix(format, "\n") { 31 | format = format + "\n" 32 | } 33 | fmt.Fprintf(os.Stderr, format, args...) 34 | os.Exit(1) 35 | } 36 | 37 | func GetDefaultGitRemote() string { 38 | gordonOrigin, err := GetGordonOrigin() 39 | if err == nil && len(gordonOrigin) > 0 { 40 | return gordonOrigin 41 | } 42 | return "origin" 43 | } 44 | 45 | func GetGordonOrigin() (string, error) { 46 | output, err := GetGitConfig("gordon.origin") 47 | if err != nil { 48 | return "", err 49 | } 50 | return string(bytes.Split(output, []byte("\n"))[0]), nil 51 | } 52 | 53 | func GetRemoteUrl(remote string) (string, string, error) { 54 | remotes, err := getRemotes() 55 | if err != nil { 56 | return "", "", err 57 | } 58 | for _, r := range remotes { 59 | if r.Name == remote { 60 | parts := strings.Split(r.Url, "/") 61 | 62 | org := parts[len(parts)-2] 63 | if i := strings.LastIndex(org, ":"); i > 0 { 64 | org = org[i+1:] 65 | } 66 | 67 | name := parts[len(parts)-1] 68 | name = strings.TrimSuffix(name, ".git") 69 | 70 | return org, name, nil 71 | } 72 | } 73 | return "", "", nil 74 | } 75 | 76 | func GetGitConfig(name string) ([]byte, error) { 77 | cmd := exec.Command("git", "config", name) 78 | PrintVerboseCommand(cmd) 79 | output, err := cmd.Output() 80 | if err != nil { 81 | return nil, fmt.Errorf("git config %s: %v", name, err) 82 | } 83 | return output, nil 84 | } 85 | 86 | func GetMaintainerManagerEmail() (string, error) { 87 | output, err := GetGitConfig("user.email") 88 | if err != nil { 89 | return "", err 90 | } 91 | return string(bytes.Split(output, []byte("\n"))[0]), nil 92 | } 93 | 94 | // Return the remotes for the current dir 95 | func getRemotes() ([]remote, error) { 96 | cmd := exec.Command("git", "remote", "-v") 97 | PrintVerboseCommand(cmd) 98 | output, err := cmd.Output() 99 | if err != nil { 100 | return nil, err 101 | } 102 | out := []remote{} 103 | s := bufio.NewScanner(bytes.NewBuffer(output)) 104 | for s.Scan() { 105 | o := remote{} 106 | if _, err := fmt.Sscan(s.Text(), &o.Name, &o.Url); err != nil { 107 | return nil, err 108 | } 109 | out = append(out, o) 110 | } 111 | 112 | return out, nil 113 | } 114 | 115 | // Execute git commands and output to 116 | // Stdout and Stderr 117 | func Git(args ...string) error { 118 | cmd := exec.Command("git", args...) 119 | PrintVerboseCommand(cmd) 120 | cmd.Stderr = os.Stderr 121 | cmd.Stdout = os.Stdout 122 | 123 | return cmd.Run() 124 | } 125 | 126 | func GetTopLevelGitRepo() (string, error) { 127 | cmd := exec.Command("git", "rev-parse", "--show-toplevel") 128 | PrintVerboseCommand(cmd) 129 | output, err := cmd.Output() 130 | if err != nil { 131 | return "", err 132 | } 133 | return strings.Trim(string(output), "\n"), nil 134 | } 135 | -------------------------------------------------------------------------------- /pkg/gordon/version.go: -------------------------------------------------------------------------------- 1 | package gordon 2 | 3 | const Version = "1" 4 | -------------------------------------------------------------------------------- /pkg/legacy/AUTHORS: -------------------------------------------------------------------------------- 1 | Nick Stinemates 2 | -------------------------------------------------------------------------------- /pkg/legacy/Dockerfile: -------------------------------------------------------------------------------- 1 | from ubuntu:12.04 2 | maintainer Nick Stinemates 3 | 4 | run apt-get install -y python-setuptools 5 | run easy_install pip 6 | 7 | add . /website 8 | run pip install -r /website/requirements.txt 9 | env PYTHONPATH /website 10 | expose 5000 11 | 12 | cmd ["python", "website/web/server.py"] 13 | -------------------------------------------------------------------------------- /pkg/legacy/LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /pkg/legacy/MAINTAINERS: -------------------------------------------------------------------------------- 1 | Nick Stinemates (@keeb) 2 | 3 | -------------------------------------------------------------------------------- /pkg/legacy/NOTICE: -------------------------------------------------------------------------------- 1 | Gordon 2 | Copyright 2012-2013 dotCloud, inc. 3 | 4 | This product includes software developed at dotCloud, inc. (http://www.dotcloud.com). 5 | 6 | Transfers of Gordon shall be in accordance with applicable export controls of any country and all other applicable 7 | legal requirements. Docker shall not be distributed or downloaded to or in Cuba, Iran, North Korea, Sudan or Syria 8 | and shall not be distributed or downloaded to any person on the Denied Persons List administered by the U.S. 9 | Department of Commerce. 10 | -------------------------------------------------------------------------------- /pkg/legacy/README.md: -------------------------------------------------------------------------------- 1 | # Gordon 2 | 3 | Gordon is a multi-function robot providing a presence via the Web and IRC, while exposing a rich API. If you're contributing to Docker, his goal is to make your life AWESOME. 4 | 5 | If you're interested in how non-trivial projects can be built, tested, distributed, or run via Docker, Gordon's goal is to provide a reference which adheres to the best practices developed by the community in these areas. 6 | 7 | Gordon, like Docker, is only as strong as the community around it. Civil discourse is encouraged. Pull Requests and Issues are appreciated. 8 | 9 | # Development Stack 10 | 11 | * Python 12 | * RQ (Tasks) 13 | * Flask (Web) 14 | * Redis (Cache) 15 | * MySQL (Persistence) 16 | 17 | # Dependencies 18 | 19 | A thorough list of python dependencies are available in the requirements.txt or README.md of each component and are buildable by their Dockerfile. See the `deps` directory. 20 | 21 | * Bender - http://github.com/dotcloud/bender 22 | 23 | # Components 24 | 25 | ## GitHub WebHooks 26 | 27 | The GitHub API is feature rich and allows for immense workflow customization/integration. Gordon takes full advantage of this by implementing a router-based event dispatch (see: `api/router.py`). Adding custom functionality is as simple as creating a new python object which operates on `content`. 28 | 29 | Out of box, Gordon offers 30 | 31 | * Automatic Pull Request Assignment to Maintainers 32 | * Caching of data in realtime, as opposed polling 33 | 34 | ## IRC Presence 35 | 36 | Command line automation and stewardship is the name of the game. 37 | 38 | ### Standup (Bender) 39 | 40 | 41 | ## Web Presence 42 | 43 | ## Caching 44 | 45 | 46 | -------------------------------------------------------------------------------- /pkg/legacy/adapters/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/docker/gordon/e3048ba690fbac0fd032dcd9382b7dd326c09bbc/pkg/legacy/adapters/__init__.py -------------------------------------------------------------------------------- /pkg/legacy/adapters/git.py: -------------------------------------------------------------------------------- 1 | import operator 2 | import posixpath 3 | 4 | from github import Github as git 5 | from urllib import urlopen 6 | 7 | from web.config import properties 8 | 9 | def rank_file_changes(flist): 10 | ire = {} 11 | 12 | for f in flist: 13 | print('working on file: {0}'.format(f.filename)) 14 | dire = posixpath.dirname(f.filename) 15 | if dire == '': 16 | dire = '/' 17 | 18 | if ire.get(dire): 19 | score = ire.get(dire) + f.changes 20 | else: 21 | score = f.changes 22 | ire[dire] = score 23 | 24 | sorted_ire = sorted(ire.iteritems(), key=operator.itemgetter(1), reverse=True) 25 | return sorted_ire 26 | 27 | 28 | def get_lead_maintainer(issue): 29 | repo = get_repo() 30 | p = repo.get_pull(issue) 31 | files = p.get_files() 32 | lead_maintainer_file = rank_file_changes(files)[0][0] 33 | maintainer_handle = _maintainer_from_path(lead_maintainer_file) 34 | return maintainer_handle 35 | 36 | def get_all_maintainers(issue): 37 | repo = get_repo() 38 | p = repo.get_pull(issue) 39 | files = p.get_files() 40 | maintainers = [] 41 | for f in files: 42 | fpath = posixpath.dirname(f.filename) 43 | if fpath == '': 44 | fpath = '/' 45 | maintainer = _maintainer_from_path(fpath) 46 | print maintainer 47 | if maintainer not in maintainers: 48 | maintainers.append(maintainer) 49 | return maintainers 50 | 51 | def check_pull_request_commits(number): 52 | repo = get_repo() 53 | pull = repo.get_pull(number) 54 | for commit in pull.get_commits(): 55 | email = commit.commit.author.email 56 | real_name = commit.commit.author.name 57 | message = commit.commit.message 58 | signed = 'Signed-off-by: {0} <{1}>'.format(real_name, email) in message 59 | if signed: 60 | commit.create_status('success', description='Commit has been properly signed.') 61 | else: 62 | commit.create_status('error', description='This commit has not been properly signed. Please rebase with a proper commit message.') 63 | 64 | 65 | 66 | 67 | 68 | 69 | def _maintainer_from_path(path): 70 | repo_name = properties.get('GITHUB_REPO') 71 | 72 | base_url = "http://raw.github.com/{0}/master".format(repo_name) 73 | print('base_url is {0}'.format(base_url)) 74 | # based on a path, traverse it backward until you find the maintainer. 75 | url = '{0}/{1}/MAINTAINERS'.format(base_url, path) 76 | print url 77 | maintainer = urlopen(url).readline() 78 | try: 79 | print maintainer 80 | maintainer_handle = maintainer.split('@')[2].strip()[:-1] 81 | print('read MAINTAINER from {0} and maintainer handle is {1}'.format(url, maintainer_handle)) 82 | return maintainer_handle 83 | except: 84 | print('unable to parse maintainer file. invalid format.') 85 | return _maintainer_from_path('/'.join(path.split('/')[:-1])) 86 | 87 | 88 | def auth_git(): 89 | return git(properties.get('GITHUB_USERNAME'), properties.get('GITHUB_PASSWORD'), timeout=3000) 90 | 91 | def get_repo(): 92 | g = auth_git() 93 | print('getting repo {0}'.format(properties.get('GITHUB_REPO'))) 94 | docker_repo = g.get_repo(properties.get('GITHUB_REPO')) 95 | return docker_repo 96 | 97 | def create_comment(number, body, *args, **kwargs): 98 | repo = get_repo() 99 | pull = repo.get_issue(number) 100 | pull.create_comment(body, *args, **kwargs) 101 | 102 | def assign_issue(number, user): 103 | g = auth_git() 104 | r = g.get_repo(properties.get('GITHUB_REPO')) 105 | i = r.get_issue(number) 106 | print('assigning issue#{0} to {1} on repo {2}'.format(number, user, properties.get('GITHUB_REPO'))) 107 | u = g.get_user(user) 108 | i.edit(assignee=u) 109 | 110 | def update_status(commit_id, state, **kwargs): 111 | g = auth_git() 112 | repo = g.get_repo(properties.get('GITHUB_REPO')) 113 | commit = repo.get_commit(commit_id) 114 | commit.create_status(state, **kwargs) 115 | print "created status.." 116 | 117 | 118 | def issues(*args, **kwargs): 119 | return [z for z in get_repo().get_issues(*args, **kwargs)] 120 | 121 | def pulls(*args, **kwargs): 122 | return [z for z in get_repo().get_pulls(*args, **kwargs)] 123 | 124 | def commits(*args, **kwargs): 125 | return [z for z in get_repo().get_commits(*args, **kwargs)] 126 | 127 | -------------------------------------------------------------------------------- /pkg/legacy/adapters/redis.py: -------------------------------------------------------------------------------- 1 | import redis 2 | from config import properties 3 | 4 | 5 | class RedisAdapter(object): 6 | def __init__(self): 7 | self.connection = redis.StrictRedis(host=properties.get('REDIS_HOST'), port=int(properties.get('REDIS_PORT'))) 8 | 9 | def add(prefix, num, di): 10 | if not isinstance(di, dict): return 11 | self.connection.hmset('{0}:{1}'.format(prefix, num), di) 12 | 13 | def zadd(key, weight, relationship): 14 | "# relationship is k:v" 15 | self.connection.zadd(key, weight, relationship) 16 | 17 | def push(name, prefix, num): 18 | key = "{0}:{1}".format(prefix, num) 19 | self.connection.rpush(name, key) 20 | 21 | def delete(key): 22 | self.connection.delete(key) 23 | 24 | def construct_issue_list_from_range(key, start=0, stop=4): 25 | print "getting key {0}".format(key) 26 | ret = [] 27 | for issue in r.lrange(key, start, stop): 28 | j = r.hgetall(issue) 29 | i = Issue(j['number'], j['title'], j['created_at']) 30 | ret.append(i) 31 | return ret 32 | 33 | def construct_committer_list_from_range(key, start=0, stop=4): 34 | ret = [] 35 | for committer in r.zrevrange(key, start, stop): 36 | c = r.hgetall(committer) 37 | i = Committer(c['name'], c['commits']) 38 | ret.append(i) 39 | return ret 40 | 41 | def get_top_contributors(): 42 | return construct_committer_list_from_range("top-committers") 43 | 44 | def get_oldest_issues(): 45 | return construct_issue_list_from_range("oldest-issues") 46 | 47 | def get_oldest_pulls(): 48 | return construct_issue_list_from_range("oldest-pulls") 49 | 50 | def get_least_pulls(): 51 | return construct_issue_list_from_range("least-updated-pulls") 52 | 53 | def get_least_issues(): 54 | return construct_issue_list_from_range("least-updated-issues") 55 | 56 | redis_connection = RedisAdapter() 57 | -------------------------------------------------------------------------------- /pkg/legacy/api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/docker/gordon/e3048ba690fbac0fd032dcd9382b7dd326c09bbc/pkg/legacy/api/__init__.py -------------------------------------------------------------------------------- /pkg/legacy/api/adapters.py: -------------------------------------------------------------------------------- 1 | class BaseAdapter(object): 2 | def __init__(self): 3 | self.listeners = [] 4 | 5 | def add_listener(self, listener): 6 | self.listeners.append(listener) 7 | 8 | def handle(self, content): 9 | for listener in self.listeners: 10 | ret = listener.event_fired(content) 11 | 12 | 13 | class PullRequestAdapter(BaseAdapter): 14 | def __init__(self): 15 | super(PullRequestAdapter, self).__init__() 16 | 17 | 18 | class PushAdapter(BaseAdapter): 19 | def __init__(self): 20 | super(PushAdapter, self).__init__() 21 | 22 | 23 | -------------------------------------------------------------------------------- /pkg/legacy/api/model.py: -------------------------------------------------------------------------------- 1 | class Sender(object): 2 | def __init__(self): 3 | pass 4 | def from_json(self, json): 5 | pass 6 | 7 | class User(object): 8 | def __init__(self): 9 | pass 10 | 11 | def from_json(self, json): 12 | self.login = json.get("login") 13 | 14 | class Repository(object): 15 | def __init__(self): 16 | pass 17 | 18 | def from_json(self, json): 19 | pass 20 | -------------------------------------------------------------------------------- /pkg/legacy/api/router.py: -------------------------------------------------------------------------------- 1 | from adapters import PullRequestAdapter 2 | from adapters import PushAdapter 3 | 4 | from listeners.pulls import AutomaticPR 5 | from listeners.pulls import DCOPullListener 6 | 7 | import logging 8 | 9 | log = logging.getLogger('router') 10 | 11 | def route_and_handle(headers, body): 12 | hooktype = headers.get('X-Github-Event') 13 | log.debug('recieved hooktype {0}'.format(hooktype)) 14 | 15 | if hooktype == "pull_request": 16 | pr = PullRequestAdapter() 17 | pr.add_listener(DCOPullListener()) 18 | pr.handle(body) 19 | elif hooktype == "push": 20 | pu = PushAdapter() 21 | pu.handle(body) 22 | -------------------------------------------------------------------------------- /pkg/legacy/build-cache: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from tasks.cache import cache_issues, cache_pulls, oldest_issues, oldest_pulls 4 | from tasks.cache import least_issues, least_pulls, issues_closed_since 5 | 6 | 7 | cache_issues() 8 | cache_pulls() 9 | oldest_issues() 10 | oldest_pulls() 11 | least_issues() 12 | least_pulls() 13 | 14 | issues_closed_since(start=0, days=7) 15 | issues_closed_since(start=7, days=14) 16 | 17 | -------------------------------------------------------------------------------- /pkg/legacy/build.py: -------------------------------------------------------------------------------- 1 | from tasks.cache import cache_issues 2 | from tasks.cache import cache_pulls 3 | from tasks.cache import cache_commits 4 | from tasks.cache import oldest_issues 5 | from tasks.cache import oldest_pulls 6 | from tasks.cache import least_issues 7 | from tasks.cache import least_pulls 8 | from tasks.cache import issues_closed_since 9 | from tasks.cache import issues_opened_since 10 | from tasks.cache import unassigned_pulls 11 | 12 | #add top issue closer 13 | #base stuff 14 | 15 | #cache_issues() 16 | #cache_pulls() 17 | #cache_commits() 18 | 19 | # filters / views 20 | #oldest_issues() 21 | #oldest_pulls() 22 | #least_issues() 23 | #least_pulls() 24 | 25 | #issues_closed_since(start=0, days=7) 26 | #issues_closed_since(start=7, days=14) 27 | 28 | issues_opened_since(start=0, days=7) 29 | issues_opened_since(start=7, days=14) 30 | 31 | #unassigned_pulls() 32 | 33 | -------------------------------------------------------------------------------- /pkg/legacy/init/hooks.py: -------------------------------------------------------------------------------- 1 | from github import Github as git 2 | from config import properties 3 | 4 | def auth_git(): 5 | return git(properties.get('GITHUB_USERNAME'), properties.get('GITHUB_PASSWORD'), timeout=3000) 6 | 7 | g = auth_git() 8 | user = g.get_organization("dotcloud") 9 | docker_repo = user.get_repo("docker") 10 | hooks = ['push', 'issues', 'issue_comment', 'commit_comment', 'pull_request', 11 | 'pull_request_review_comment', 'watch', 'fork', 'fork_apply'] 12 | config = {'url': 'http://api.stinemat.es', 'content_type': 'json'} 13 | 14 | docker_repo.create_hook('web', config, hooks, True) 15 | 16 | -------------------------------------------------------------------------------- /pkg/legacy/launch: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # get the real username and password 4 | source .api_credentials 5 | 6 | HIPACHE_CONTAINER=$(docker ps | grep hipache | cut -d" " -f1) 7 | 8 | if [[ -z "$HIPACHE_CONTAINER" ]]; then 9 | echo "hipache is not running" 10 | exit 1 11 | fi 12 | 13 | HOST=172.17.42.1 14 | GORDON_DOMAIN=$GORDON_HOST 15 | 16 | REDIS_PORT=$(docker port $HIPACHE_CONTAINER 6379) 17 | REDIS_HOST=$HOST 18 | 19 | API_CONTAINER=$(docker run -d \ 20 | -e GITHUB_USERNAME=$GITHUB_USERNAME \ 21 | -e GITHUB_PASSWORD=$GITHUB_PASSWORD \ 22 | -e GITHUB_REPO="dotcloud/docker" \ 23 | -e REDIS_HOST=$REDIS_HOST \ 24 | -e REDIS_PORT=$REDIS_PORT \ 25 | registry.stinemat.es/keeb/gordon) 26 | 27 | API_PORT=$(docker port $API_CONTAINER 5000) 28 | API_HOST=$HOST 29 | 30 | # clear out old keys 31 | redis-cli -h $REDIS_HOST -p $REDIS_PORT del frontend:$GORDON_DOMAIN 32 | redis-cli -h $REDIS_HOST -p $REDIS_PORT rpush frontend:$GORDON_DOMAIN api 33 | redis-cli -h $REDIS_HOST -p $REDIS_PORT rpush frontend:$GORDON_DOMAIN http://$API_HOST:$API_PORT 34 | 35 | -------------------------------------------------------------------------------- /pkg/legacy/listeners/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/docker/gordon/e3048ba690fbac0fd032dcd9382b7dd326c09bbc/pkg/legacy/listeners/__init__.py -------------------------------------------------------------------------------- /pkg/legacy/listeners/pulls.py: -------------------------------------------------------------------------------- 1 | import operator 2 | 3 | from urllib import urlopen 4 | from adapters import git 5 | from adapters.git import get_all_maintainers 6 | from adapters.git import assign_issue 7 | from adapters.git import create_comment 8 | from adapters.git import check_pull_request_commits 9 | # rename that shit. 10 | 11 | from web.config import properties 12 | 13 | class AutomaticPR(object): 14 | def __init__(self): 15 | pass 16 | 17 | def event_fired(self, content): 18 | if content.get('action') != "opened": 19 | # we really don't care about events that are related to pull requests being opened. 20 | return 21 | 22 | num = content.get('pull_request').get('number') 23 | maintainers = get_all_maintainers(num) 24 | assign_issue(num, 'gordn') 25 | create_comment(num, 'Hey {0}, can you please take a look at this issue?', ','.join(maintainers)) 26 | 27 | class DCOPullListener(object): 28 | def __init__(self): 29 | pass 30 | 31 | def event_fired(self, content): 32 | number = content.get('pull_request').get('number') 33 | check_pull_request_commits(number) 34 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /pkg/legacy/providers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/docker/gordon/e3048ba690fbac0fd032dcd9382b7dd326c09bbc/pkg/legacy/providers/__init__.py -------------------------------------------------------------------------------- /pkg/legacy/providers/cache.py: -------------------------------------------------------------------------------- 1 | import redis 2 | 3 | from web.config import properties 4 | from web.model import Issue 5 | 6 | redis_connection = redis.StrictRedis(host=properties.get('REDIS_HOST'), port=int(properties.get('REDIS_PORT'))) 7 | 8 | 9 | def retrieve_view(key, start=0, stop=4): 10 | return map(lambda x: obj_from_key(x), redis_connection.lrange(key, start, stop)) 11 | 12 | def obj_from_key(key): 13 | if "issue:" in key or "pull:" in key: 14 | return Issue().from_dict(redis_connection.hgetall(key)) 15 | 16 | -------------------------------------------------------------------------------- /pkg/legacy/requirements.txt: -------------------------------------------------------------------------------- 1 | Flask==1.0 2 | PyGithub==1.17.0 3 | redis==2.7.6 4 | -------------------------------------------------------------------------------- /pkg/legacy/tasks/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/docker/gordon/e3048ba690fbac0fd032dcd9382b7dd326c09bbc/pkg/legacy/tasks/__init__.py -------------------------------------------------------------------------------- /pkg/legacy/tasks/cache.py: -------------------------------------------------------------------------------- 1 | from adapters.git import issues, pulls, commits 2 | from providers.cache import redis_connection as r 3 | from providers.cache import obj_from_key 4 | 5 | def ranked_committers(lst): 6 | ret = {} 7 | for commit in lst: 8 | try: 9 | login = commit.committer.login 10 | except Exception, e: 11 | print commit.sha 12 | 13 | if ret.get(login): 14 | i = ret[login] 15 | i+= 1 16 | ret[login] = i 17 | else: 18 | ret[login] = 1 19 | return ret 20 | 21 | 22 | def cache_issues(): 23 | open_issue_list = issues(state="open") 24 | closed_issue_list = issues(state="closed") 25 | for issue in open_issue_list: 26 | prefix = "issue" 27 | suffix = issue.number 28 | json_dict = issue.__dict__.get('_rawData') 29 | r.hmset('{0}:{1}'.format(prefix, suffix), json_dict) 30 | 31 | for issue in closed_issue_list: 32 | prefix = "issue" 33 | suffix = issue.number 34 | json_dict = issue.__dict__.get('_rawData') 35 | r.hmset('{0}:{1}'.format(prefix, suffix), json_dict) 36 | 37 | def cache_pulls(): 38 | open_pull_list = pulls(state="open") 39 | closed_pull_list = pulls(state="closed") 40 | 41 | for issue in open_pull_list: 42 | prefix = "pull" 43 | suffix = issue.number 44 | json_dict = issue.__dict__.get('_rawData') 45 | r.hmset('{0}:{1}'.format(prefix, suffix), json_dict) 46 | 47 | for issue in closed_pull_list: 48 | prefix = "pull" 49 | suffix = issue.number 50 | json_dict = issue.__dict__.get('_rawData') 51 | r.hmset('{0}:{1}'.format(prefix, suffix), json_dict) 52 | 53 | 54 | def cache_commits(): 55 | commit_list = commits() 56 | for commit in commit_list: 57 | prefix = "commit" 58 | suffix = commit.sha[:5] 59 | json_dict = commit.__dict__ 60 | r.hmset('{0}:{1}'.format(prefix, suffix), json_dict) 61 | 62 | 63 | def filter_keys(key, func): 64 | lst = r.keys(key) 65 | return filter(func, lst) 66 | 67 | def open_issues(): 68 | open_issues = filter_keys("issue:*", lambda x: obj_from_key(x).state == "open") 69 | return open_issues 70 | 71 | def open_pulls(): 72 | open_issues = filter_keys("pull:*", lambda x: obj_from_key(x).state == "open") 73 | return open_issues 74 | 75 | def closed_issues(): 76 | closed_issues = filter_keys("issue:*", lambda x: obj_from_key(x).state == "closed") 77 | return closed_issues 78 | 79 | def commits(): 80 | commits = filter_keys("commit:*", lambda x: obj_from_key(x)) 81 | 82 | def oldest(lst): 83 | return sorted(lst, key=lambda x: x.created_at) 84 | 85 | def least_updated(lst): 86 | return sorted(lst, key=lambda x: x.updated_at) 87 | 88 | def oldest_issues(): 89 | op = open_issues() 90 | ls = [obj_from_key(z) for z in op] 91 | create_view('oldest-issues', oldest(ls)) 92 | 93 | def oldest_pulls(): 94 | op = open_pulls() 95 | ls = [obj_from_key(z) for z in op] 96 | create_view('oldest-pulls', oldest(ls)) 97 | 98 | def least_issues(): 99 | op = open_issues() 100 | ls = [obj_from_key(z) for z in op] 101 | create_view('least-updated-issues', least_updated(ls)) 102 | 103 | def least_pulls(): 104 | op = open_pulls() 105 | ls = [obj_from_key(z) for z in op] 106 | create_view('least-updated-pulls', least_updated(ls)) 107 | 108 | def issues_closed_since(start=0, days=7): 109 | cl = closed_issues() 110 | ls = [obj_from_key(z) for z in cl] 111 | create_view('issues-closed-since-{0}-{1}'.format(start, days), filter_since(ls, start, days)) 112 | 113 | def issues_opened_since(start=0, days=7): 114 | cl = open_issues() 115 | ls = [obj_from_key(z) for z in cl] 116 | create_view('issues-open-since-{0}-{1}'.format(start, days), filter_since(ls, start, days)) 117 | 118 | def unassigned_pulls(): 119 | up = open_pulls() 120 | ls = [obj_from_key(z) for z in up] 121 | create_view('unassigned-prs', filter(lambda x: x.assignee is not None, ls)) 122 | 123 | def filter_since(ls, start, total_days=7): 124 | from datetime import timedelta 125 | from datetime import date 126 | if start == 0: 127 | base = date.today() 128 | else: 129 | base = date.today() - timedelta(start) 130 | date_range = [(base - timedelta(days=x)).isoformat() for x in range(0,total_days)] 131 | return filter(lambda x: x.closed_at.split("T",1)[0] in date_range, ls) 132 | 133 | 134 | def create_view(key, lst): 135 | #lst should have objects which have a __key__ method. 136 | r.delete(key) 137 | for i in lst: 138 | k = i.__key__() 139 | r.rpush(key, k) 140 | 141 | 142 | 143 | def build_cache(): 144 | pulls = [z for z in pulls()] 145 | commits = [z for z in commits()] 146 | map(lambda x: add("issue", x.number, x.__dict__['_rawData']), issues) 147 | map(lambda x: add("pull", x.number, x.__dict__['_rawData']), pulls) 148 | map(lambda x: push("oldest-issues", "issue", x.number), oldest(issues)) 149 | map(lambda x: push("oldest-pulls", "pull", x.number), oldest(pulls)) 150 | map(lambda x: push("least-updated-pulls", "pull", x.number), least_updated(pulls)) 151 | map(lambda x: push("least-updated-issues", "issue", x.number), least_updated(issues)) 152 | map(lambda x: add("commit", x.sha[:5], x.__dict__['_rawData']), commits) 153 | 154 | 155 | x = 1 156 | for committer, commit_num in ranked_committers(commits).iteritems(): 157 | add('committer', str(x), {'name': committer, 'commits': commit_num}) 158 | zadd('top-committers', commit_num, "{0}:{1}".format('committer', str(x))) 159 | x += 1 160 | 161 | 162 | add("unique-committers-count", "1", {'total': str(len(ranked_committers(commits)))}) 163 | add("total-commits", "1", {'total': str(len(commits))}) 164 | -------------------------------------------------------------------------------- /pkg/legacy/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/docker/gordon/e3048ba690fbac0fd032dcd9382b7dd326c09bbc/pkg/legacy/tests/__init__.py -------------------------------------------------------------------------------- /pkg/legacy/tests/integration/data.json: -------------------------------------------------------------------------------- 1 | {"action": "opened", 2 | "pull_request": { 3 | "number": 1909 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /pkg/legacy/tests/integration/test.sh: -------------------------------------------------------------------------------- 1 | curl -i \ 2 | -H "Content-Type: application/json" \ 3 | -H "X-Github-Event: pull_request" \ 4 | -X POST -d "$(cat data.json)" \ 5 | http://api.stinemat.es 6 | -------------------------------------------------------------------------------- /pkg/legacy/tests/mock.py: -------------------------------------------------------------------------------- 1 | SIGNED_CLA_LOGIN = ['crosbymichael', 'shykes', 'creack', ] 2 | 3 | class CLAChecker(object): 4 | def __init__(self): 5 | pass 6 | 7 | def check_signed_cla(self, login): 8 | if login in SIGNED_CLA_LOGIN: 9 | return True 10 | return False 11 | 12 | 13 | -------------------------------------------------------------------------------- /pkg/legacy/web/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/docker/gordon/e3048ba690fbac0fd032dcd9382b7dd326c09bbc/pkg/legacy/web/__init__.py -------------------------------------------------------------------------------- /pkg/legacy/web/app.py: -------------------------------------------------------------------------------- 1 | from flask import Flask 2 | 3 | app = Flask(__name__) 4 | app.debug = True 5 | -------------------------------------------------------------------------------- /pkg/legacy/web/config.py: -------------------------------------------------------------------------------- 1 | from os import environ 2 | 3 | properties = {} 4 | 5 | properties['GITHUB_USERNAME'] = environ['GITHUB_USERNAME'] 6 | properties['GITHUB_PASSWORD'] = environ['GITHUB_PASSWORD'] 7 | properties['GITHUB_REPO'] = environ['GITHUB_REPO'] 8 | properties['REDIS_PORT'] = environ['REDIS_PORT'] 9 | properties['REDIS_HOST'] = environ['REDIS_HOST'] 10 | -------------------------------------------------------------------------------- /pkg/legacy/web/controller.py: -------------------------------------------------------------------------------- 1 | from providers.cache import retrieve_view as rv 2 | from api.router import route_and_handle 3 | from model import IssueCollection 4 | 5 | 6 | class ApiController(object): 7 | def __init__(self): 8 | pass 9 | 10 | def route(self, headers, request): 11 | ret = route_and_handle(headers, request) 12 | 13 | return ret 14 | 15 | 16 | class IssueController(object): 17 | def __init__(self): 18 | pass 19 | 20 | def get_oldest_issues(self): 21 | return rv("oldest-issues") 22 | 23 | def get_oldest_pulls(self): 24 | return rv("oldest-pulls") 25 | 26 | def get_least_issues(self): 27 | return rv('least-updated-issues') 28 | 29 | def get_least_pulls(self): 30 | return rv('least-updated-pulls') 31 | 32 | def get_top_contributors(self): 33 | return rv('top-contributors') 34 | 35 | 36 | class IssueCollectionController(object): 37 | def __init__(self): 38 | pass 39 | 40 | def get_issues_opened_count(self): 41 | # last week 42 | ls1 = len(rv('issues-open-since-0-7', stop=-1)) 43 | # week before 44 | ls2 = len(rv('issues-open-since-7-14', stop=-1)) 45 | return IssueCollection(ls1, ls2) 46 | 47 | def get_issues_closed_count(self): 48 | # last week 49 | ls1 = len(rv('issues-closed-since-0-7', stop=-1)) 50 | print ls1 51 | # week before 52 | ls2 = len(rv('issues-closed-since-7-14', stop=-1)) 53 | 54 | return IssueCollection(ls1, ls2) 55 | 56 | 57 | 58 | 59 | class CommitController(object): 60 | def __init__(self): 61 | pass 62 | 63 | 64 | -------------------------------------------------------------------------------- /pkg/legacy/web/model.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | class Issue(object): 4 | def __init__(self, number=None, title=None, created_at=None, updated_at=None, closed_at=None, state=None, assignee=None): 5 | self.number = number 6 | self.title = title 7 | self.created_at = created_at 8 | self.updated_at = updated_at 9 | self.closed_at = closed_at 10 | self.state = state 11 | self.assignee = assignee 12 | if self.created_at: 13 | self.age = self.age() 14 | 15 | def age(self): 16 | delta = (datetime.strptime(self.created_at, '%Y-%m-%dT%H:%M:%SZ') - datetime.now()).days 17 | return abs(delta) 18 | 19 | def __repr__(self): 20 | return "{0} - {1}".format(self.number, self.title) 21 | 22 | def __key__(self): 23 | return "issue:{0}".format(self.number) 24 | 25 | def from_dict(self, data): 26 | self.number = data.get('number') 27 | self.title = data.get('title') 28 | self.created_at = data.get('created_at') 29 | self.updated_at = data.get('updated_at') 30 | self.closed_at = data.get('closed_at') 31 | self.age = self.age() 32 | self.state = data.get('state') 33 | self.assignee = data.get('assignee') 34 | return self 35 | 36 | class IssueCollection(object): 37 | def __init__(self, this_week_count=None, last_week_count=None): 38 | print "initialized" 39 | self.this_week_count = this_week_count 40 | self.last_week_count = last_week_count 41 | self.difference = self.calculate_difference() 42 | 43 | 44 | def calculate_difference(self): 45 | if self.last_week_count == 0 or self.this_week_count == 0: 46 | return 'N/A' 47 | # ;\ 48 | a = float(self.last_week_count) - float(self.this_week_count) 49 | ret = (a / float(self.last_week_count)) * 100 50 | return "%.2f" % ret 51 | 52 | 53 | 54 | 55 | class Committer(object): 56 | def __init__(self, name, commits): 57 | self.name = name 58 | self.commits = commits 59 | 60 | -------------------------------------------------------------------------------- /pkg/legacy/web/server.py: -------------------------------------------------------------------------------- 1 | from app import app 2 | from views import index 3 | from views import hook 4 | from views import maintainers 5 | from views import lead_maintainer 6 | 7 | import logging 8 | 9 | logging.basicConfig(format='%(levelname)s: %(message)s', level=logging.DEBUG) 10 | 11 | app.add_url_rule('/', 'index', index, methods=['GET']) 12 | app.add_url_rule('/', 'hook', hook, methods=['POST']) 13 | app.add_url_rule('/maintainers/', 'maintainer', maintainers, methods=['GET']) 14 | app.add_url_rule('/lead_maintainer/', 'lead_maintainer', lead_maintainer, methods=['GET']) 15 | 16 | if __name__=="__main__": 17 | app.run('0.0.0.0') 18 | -------------------------------------------------------------------------------- /pkg/legacy/web/static/css/pure-min.css: -------------------------------------------------------------------------------- 1 | /*! 2 | Pure v0.2.1 3 | Copyright 2013 Yahoo! Inc. All rights reserved. 4 | Licensed under the BSD License. 5 | https://github.com/yui/pure/blob/master/LICENSE.md 6 | */ 7 | /*! 8 | normalize.css v1.1.2 | MIT License | git.io/normalize 9 | Copyright (c) Nicolas Gallagher and Jonathan Neal 10 | */ 11 | /*! normalize.css v1.1.2 | MIT License | git.io/normalize */article,aside,details,figcaption,figure,footer,header,hgroup,main,nav,section,summary{display:block}audio,canvas,video{display:inline-block;*display:inline;*zoom:1}audio:not([controls]){display:none;height:0}[hidden]{display:none}html{font-size:100%;-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%}html,button,input,select,textarea{font-family:sans-serif}body{margin:0}a:focus{outline:thin dotted}a:active,a:hover{outline:0}h1{font-size:2em;margin:.67em 0}h2{font-size:1.5em;margin:.83em 0}h3{font-size:1.17em;margin:1em 0}h4{font-size:1em;margin:1.33em 0}h5{font-size:.83em;margin:1.67em 0}h6{font-size:.67em;margin:2.33em 0}abbr[title]{border-bottom:1px dotted}b,strong{font-weight:700}blockquote{margin:1em 40px}dfn{font-style:italic}hr{-moz-box-sizing:content-box;box-sizing:content-box;height:0}mark{background:#ff0;color:#000}p,pre{margin:1em 0}code,kbd,pre,samp{font-family:monospace,serif;_font-family:'courier new',monospace;font-size:1em}pre{white-space:pre;white-space:pre-wrap;word-wrap:break-word}q{quotes:none}q:before,q:after{content:'';content:none}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sup{top:-.5em}sub{bottom:-.25em}dl,menu,ol,ul{margin:1em 0}dd{margin:0 0 0 40px}menu,ol,ul{padding:0 0 0 40px}nav ul,nav ol{list-style:none;list-style-image:none}img{border:0;-ms-interpolation-mode:bicubic}svg:not(:root){overflow:hidden}figure{margin:0}form{margin:0}fieldset{border:1px solid silver;margin:0 2px;padding:.35em .625em .75em}legend{border:0;padding:0;white-space:normal;*margin-left:-7px}button,input,select,textarea{font-size:100%;margin:0;vertical-align:baseline;*vertical-align:middle}button,input{line-height:normal}button,select{text-transform:none}button,html input[type=button],input[type=reset],input[type=submit]{-webkit-appearance:button;cursor:pointer;*overflow:visible}button[disabled],html input[disabled]{cursor:default}input[type=checkbox],input[type=radio]{box-sizing:border-box;padding:0;*height:13px;*width:13px}input[type=search]{-webkit-appearance:textfield;-moz-box-sizing:content-box;-webkit-box-sizing:content-box;box-sizing:content-box}input[type=search]::-webkit-search-cancel-button,input[type=search]::-webkit-search-decoration{-webkit-appearance:none}button::-moz-focus-inner,input::-moz-focus-inner{border:0;padding:0}textarea{overflow:auto;vertical-align:top}table{border-collapse:collapse;border-spacing:0} 12 | .pure-button{display:inline-block;*display:inline;zoom:1;line-height:normal;white-space:nowrap;vertical-align:baseline;text-align:center;cursor:pointer;-webkit-user-drag:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.pure-button::-moz-focus-inner{padding:0;border:0}.pure-button{font-size:100%;*font-size:90%;*overflow:visible;padding:.5em 1.5em;color:#444;color:rgba(0,0,0,.8);*color:#444;border:1px solid #999;border:0 rgba(0,0,0,0);background-color:#E6E6E6;text-decoration:none;border-radius:2px;-webkit-font-smoothing:antialiased;-webkit-transition:.1s linear -webkit-box-shadow;-moz-transition:.1s linear -moz-box-shadow;-ms-transition:.1s linear box-shadow;-o-transition:.1s linear box-shadow;transition:.1s linear box-shadow}.pure-button-hover,.pure-button:hover,.pure-button:focus{filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#00000000', endColorstr='#1a000000', GradientType=0);background-image:-webkit-gradient(linear,0 0,0 100%,from(transparent),color-stop(40%,rgba(0,0,0,.05)),to(rgba(0,0,0,.1)));background-image:-webkit-linear-gradient(transparent,rgba(0,0,0,.05) 40%,rgba(0,0,0,.1));background-image:-moz-linear-gradient(top,rgba(0,0,0,.05) 0,rgba(0,0,0,.1));background-image:-ms-linear-gradient(transparent,rgba(0,0,0,.05) 40%,rgba(0,0,0,.1));background-image:-o-linear-gradient(transparent,rgba(0,0,0,.05) 40%,rgba(0,0,0,.1));background-image:linear-gradient(transparent,rgba(0,0,0,.05) 40%,rgba(0,0,0,.1))}.pure-button:focus{outline:0}.pure-button-active,.pure-button:active{box-shadow:0 0 0 1px rgba(0,0,0,.15) inset,0 0 6px rgba(0,0,0,.2) inset}.pure-button[disabled],.pure-button-disabled,.pure-button-disabled:hover,.pure-button-disabled:focus,.pure-button-disabled:active{border:0;background-image:none;filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);filter:alpha(opacity=40);-khtml-opacity:.4;-moz-opacity:.4;opacity:.4;cursor:not-allowed;box-shadow:none}.pure-button-hidden{display:none}.pure-button::-moz-focus-inner{padding:0;border:0}.pure-button-primary,.pure-button-selected,a.pure-button-primary,a.pure-button-selected{background-color:#0078e7;color:#fff} 13 | .pure-form{margin:0}.pure-form fieldset{border:1px solid silver;margin:0 2px;padding:.35em .625em .75em}.pure-form legend{border:0;padding:0;white-space:normal;*margin-left:-7px}.pure-form button,.pure-form input,.pure-form select,.pure-form textarea{font-size:100%;margin:0;vertical-align:baseline;*vertical-align:middle}.pure-form button,.pure-form input{line-height:normal}.pure-form button,.pure-form input[type=button],.pure-form input[type=reset],.pure-form input[type=submit]{-webkit-appearance:button;cursor:pointer;*overflow:visible}.pure-form button[disabled],.pure-form input[disabled]{cursor:default}.pure-form input[type=checkbox],.pure-form input[type=radio]{box-sizing:border-box;padding:0;*height:13px;*width:13px}.pure-form input[type=search]{-webkit-appearance:textfield;-moz-box-sizing:content-box;-webkit-box-sizing:content-box;box-sizing:content-box}.pure-form input[type=search]::-webkit-search-cancel-button,.pure-form input[type=search]::-webkit-search-decoration{-webkit-appearance:none}.pure-form button::-moz-focus-inner,.pure-form input::-moz-focus-inner{border:0;padding:0}.pure-form textarea{overflow:auto;vertical-align:top}.pure-form input[type=text],.pure-form input[type=password],.pure-form input[type=email],.pure-form input[type=url],.pure-form input[type=date],.pure-form input[type=month],.pure-form input[type=time],.pure-form input[type=datetime],.pure-form input[type=datetime-local],.pure-form input[type=week],.pure-form input[type=number],.pure-form input[type=search],.pure-form input[type=tel],.pure-form input[type=color],.pure-form select,.pure-form textarea{padding:.5em .6em;display:inline-block;border:1px solid #ccc;font-size:.8em;box-shadow:inset 0 1px 3px #ddd;border-radius:4px;-webkit-transition:.3s linear border;-moz-transition:.3s linear border;-ms-transition:.3s linear border;-o-transition:.3s linear border;transition:.3s linear border;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box;-webkit-font-smoothing:antialiased}.pure-form input[type=text]:focus,.pure-form input[type=password]:focus,.pure-form input[type=email]:focus,.pure-form input[type=url]:focus,.pure-form input[type=date]:focus,.pure-form input[type=month]:focus,.pure-form input[type=time]:focus,.pure-form input[type=datetime]:focus,.pure-form input[type=datetime-local]:focus,.pure-form input[type=week]:focus,.pure-form input[type=number]:focus,.pure-form input[type=search]:focus,.pure-form input[type=tel]:focus,.pure-form input[type=color]:focus,.pure-form select:focus,.pure-form textarea:focus{outline:0;outline:thin dotted \9;border-color:#129FEA}.pure-form input[type=file]:focus,.pure-form input[type=radio]:focus,.pure-form input[type=checkbox]:focus{outline:thin dotted #333;outline:1px auto #129FEA}.pure-form .pure-checkbox,.pure-form .pure-radio{margin:.5em 0;display:block}.pure-form input[type=text][disabled],.pure-form input[type=password][disabled],.pure-form input[type=email][disabled],.pure-form input[type=url][disabled],.pure-form input[type=date][disabled],.pure-form input[type=month][disabled],.pure-form input[type=time][disabled],.pure-form input[type=datetime][disabled],.pure-form input[type=datetime-local][disabled],.pure-form input[type=week][disabled],.pure-form input[type=number][disabled],.pure-form input[type=search][disabled],.pure-form input[type=tel][disabled],.pure-form input[type=color][disabled],.pure-form select[disabled],.pure-form textarea[disabled]{cursor:not-allowed;background-color:#eaeded;color:#cad2d3}.pure-form input[readonly],.pure-form select[readonly],.pure-form textarea[readonly],.pure-form input[readonly]:focus,.pure-form select[readonly]:focus,.pure-form textarea[readonly]:focus{background:#eee;color:#777;border-color:#ccc}.pure-form input:focus:invalid,.pure-form textarea:focus:invalid,.pure-form select:focus:invalid{color:#b94a48;border:1px solid #ee5f5b}.pure-form input:focus:invalid:focus,.pure-form textarea:focus:invalid:focus,.pure-form select:focus:invalid:focus{border-color:#e9322d}.pure-form input[type=file]:focus:invalid:focus,.pure-form input[type=radio]:focus:invalid:focus,.pure-form input[type=checkbox]:focus:invalid:focus{outline-color:#e9322d}.pure-form select{border:1px solid #ccc;background-color:#fff}.pure-form select[multiple]{height:auto}.pure-form label{margin:.5em 0 .2em;font-size:90%}.pure-form fieldset{margin:0;padding:.35em 0 .75em;border:0}.pure-form legend{display:block;width:100%;padding:.3em 0;margin-bottom:.3em;font-size:125%;color:#333;border-bottom:1px solid #e5e5e5}.pure-form-stacked input[type=text],.pure-form-stacked input[type=password],.pure-form-stacked input[type=email],.pure-form-stacked input[type=url],.pure-form-stacked input[type=date],.pure-form-stacked input[type=month],.pure-form-stacked input[type=time],.pure-form-stacked input[type=datetime],.pure-form-stacked input[type=datetime-local],.pure-form-stacked input[type=week],.pure-form-stacked input[type=number],.pure-form-stacked input[type=search],.pure-form-stacked input[type=tel],.pure-form-stacked input[type=color],.pure-form-stacked select,.pure-form-stacked label,.pure-form-stacked textarea{display:block;margin:.25em 0}.pure-form-aligned input,.pure-form-aligned textarea,.pure-form-aligned select,.pure-form-aligned .pure-help-inline,.pure-form-message-inline{display:inline-block;*display:inline;*zoom:1;vertical-align:middle}.pure-form-aligned .pure-control-group{margin-bottom:.5em}.pure-form-aligned .pure-control-group label{text-align:right;display:inline-block;vertical-align:middle;width:10em;margin:0 1em 0 0}.pure-form-aligned .pure-controls{margin:1.5em 0 0 10em}.pure-form input.pure-input-rounded,.pure-form .pure-input-rounded{border-radius:2em;padding:.5em 1em}.pure-form .pure-group fieldset{margin-bottom:10px}.pure-form .pure-group input{display:block;padding:10px;margin:0;border-radius:0;position:relative;top:-1px}.pure-form .pure-group input:focus{z-index:2}.pure-form .pure-group input:first-child{top:1px;border-radius:4px 4px 0 0}.pure-form .pure-group input:last-child{top:-2px;border-radius:0 0 4px 4px}.pure-form .pure-group button{margin:.35em 0}.pure-form .pure-input-1{width:100%}.pure-form .pure-input-2-3{width:66%}.pure-form .pure-input-1-2{width:50%}.pure-form .pure-input-1-3{width:33%}.pure-form .pure-input-1-4{width:25%}.pure-form .pure-help-inline,.pure-form-message-inline{display:inline-block;padding-left:.3em;color:#666;vertical-align:middle;font-size:90%}.pure-form-message{display:block;color:#666;font-size:90%}@media only screen and (max-width :480px){.pure-form button[type=submit]{margin:.7em 0 0}.pure-form input[type=text],.pure-form input[type=password],.pure-form input[type=email],.pure-form input[type=url],.pure-form input[type=date],.pure-form input[type=month],.pure-form input[type=time],.pure-form input[type=datetime],.pure-form input[type=datetime-local],.pure-form input[type=week],.pure-form input[type=number],.pure-form input[type=search],.pure-form input[type=tel],.pure-form input[type=color],.pure-form label{margin-bottom:.3em;display:block}.pure-group input[type=text],.pure-group input[type=password],.pure-group input[type=email],.pure-group input[type=url],.pure-group input[type=date],.pure-group input[type=month],.pure-group input[type=time],.pure-group input[type=datetime],.pure-group input[type=datetime-local],.pure-group input[type=week],.pure-group input[type=number],.pure-group input[type=search],.pure-group input[type=tel],.pure-group input[type=color]{margin-bottom:0}.pure-form-aligned .pure-control-group label{margin-bottom:.3em;text-align:left;display:block;width:100%}.pure-form-aligned .pure-controls{margin:1.5em 0 0}.pure-form .pure-help-inline,.pure-form-message-inline,.pure-form-message{display:block;font-size:80%;padding:.2em 0 .8em}} 14 | .pure-g{letter-spacing:-.31em;*letter-spacing:normal;*word-spacing:-.43em;text-rendering:optimizespeed}.opera-only :-o-prefocus,.pure-g{word-spacing:-.43em}.pure-u{display:inline-block;*display:inline;zoom:1;letter-spacing:normal;word-spacing:normal;vertical-align:top;text-rendering:auto}.pure-u-1,.pure-u-1-2,.pure-u-1-3,.pure-u-2-3,.pure-u-1-4,.pure-u-3-4,.pure-u-1-5,.pure-u-2-5,.pure-u-3-5,.pure-u-4-5,.pure-u-1-6,.pure-u-5-6,.pure-u-1-8,.pure-u-3-8,.pure-u-5-8,.pure-u-7-8,.pure-u-1-12,.pure-u-5-12,.pure-u-7-12,.pure-u-11-12,.pure-u-1-24,.pure-u-5-24,.pure-u-7-24,.pure-u-11-24,.pure-u-13-24,.pure-u-17-24,.pure-u-19-24,.pure-u-23-24{display:inline-block;*display:inline;zoom:1;letter-spacing:normal;word-spacing:normal;vertical-align:top;text-rendering:auto}.pure-u-1{width:100%}.pure-u-1-2{width:50%}.pure-u-1-3{width:33.33333%}.pure-u-2-3{width:66.66666%}.pure-u-1-4{width:25%}.pure-u-3-4{width:75%}.pure-u-1-5{width:20%}.pure-u-2-5{width:40%}.pure-u-3-5{width:60%}.pure-u-4-5{width:80%}.pure-u-1-6{width:16.666%}.pure-u-5-6{width:83.33%}.pure-u-1-8{width:12.5%}.pure-u-3-8{width:37.5%}.pure-u-5-8{width:62.5%}.pure-u-7-8{width:87.5%}.pure-u-1-12{width:8.3333%}.pure-u-5-12{width:41.6666%}.pure-u-7-12{width:58.3333%}.pure-u-11-12{width:91.6666%}.pure-u-1-24{width:4.1666%}.pure-u-5-24{width:20.8333%}.pure-u-7-24{width:29.1666%}.pure-u-11-24{width:45.8333%}.pure-u-13-24{width:54.1666%}.pure-u-17-24{width:70.8333%}.pure-u-19-24{width:79.1666%}.pure-u-23-24{width:95.8333%}.pure-g-r{letter-spacing:-.31em;*letter-spacing:normal;*word-spacing:-.43em}.opera-only :-o-prefocus,.pure-g-r{word-spacing:-.43em}.pure-g-r img{max-width:100%}@media (min-width:980px){.pure-visible-phone{display:none}.pure-visible-tablet{display:none}.pure-hidden-desktop{display:none}}@media (max-width:480px){.pure-g-r>.pure-u,.pure-g-r>[class *="pure-u-"]{width:100%}}@media (max-width:767px){.pure-g-r>.pure-u,.pure-g-r>[class *="pure-u-"]{width:100%}.pure-hidden-phone{display:none}.pure-visible-desktop{display:none}}@media (min-width:768px) and (max-width:979px){.pure-hidden-tablet{display:none}.pure-visible-desktop{display:none}} 15 | .pure-menu ul{position:absolute;visibility:hidden}.pure-menu.pure-menu-open{visibility:visible;z-index:2;width:100%}.pure-menu ul{left:-10000px;list-style:none;margin:0;padding:0;top:-10000px;z-index:1}.pure-menu>ul{position:relative}.pure-menu-open>ul{left:0;top:0;visibility:visible}.pure-menu-open>ul:focus{outline:0}.pure-menu li{position:relative}.pure-menu a,.pure-menu .pure-menu-heading{display:block;color:inherit;line-height:1.5em;padding:5px 20px;text-decoration:none;white-space:nowrap}.pure-menu.pure-menu-horizontal>.pure-menu-heading{display:inline-block;*display:inline;zoom:1;margin:0;vertical-align:middle}.pure-menu.pure-menu-horizontal>ul{display:inline-block;*display:inline;zoom:1;vertical-align:middle;height:2.4em}.pure-menu li a{padding:5px 20px}.pure-menu-can-have-children>.pure-menu-label:after{content:'\25B8';float:right;font-family:'Lucida Grande','Lucida Sans Unicode','DejaVu Sans',sans-serif;margin-right:-20px;margin-top:-1px}.pure-menu-can-have-children>.pure-menu-label{padding-right:30px}.pure-menu-separator{background-color:#dfdfdf;display:block;height:1px;font-size:0;margin:7px 2px;overflow:hidden}.pure-menu-hidden{display:none}.pure-menu-fixed{position:fixed;top:0;left:0;width:100%}.pure-menu-horizontal li{display:inline-block;*display:inline;zoom:1;vertical-align:middle}.pure-menu-horizontal li li{display:block}.pure-menu-horizontal>.pure-menu-children>.pure-menu-can-have-children>.pure-menu-label:after{content:"\25BE"}.pure-menu-horizontal>.pure-menu-children>.pure-menu-can-have-children>.pure-menu-label{padding-right:30px}.pure-menu-horizontal li.pure-menu-separator{height:50%;width:1px;margin:0 7px}.pure-menu-horizontal li li.pure-menu-separator{height:1px;width:auto;margin:7px 2px}.pure-menu.pure-menu-open,.pure-menu.pure-menu-horizontal li .pure-menu-children{background:#fff;border:1px solid #b7b7b7}.pure-menu.pure-menu-horizontal,.pure-menu.pure-menu-horizontal .pure-menu-heading{border:0}.pure-menu a{border:1px solid transparent;border-left:0;border-right:0}.pure-menu a,.pure-menu .pure-menu-can-have-children>li:after{color:#777}.pure-menu .pure-menu-can-have-children>li:hover:after{color:#fff}.pure-menu .pure-menu-open{background:#dedede}.pure-menu li a:hover,.pure-menu li a:focus{background:#eee}.pure-menu li.pure-menu-disabled a:hover,.pure-menu li.pure-menu-disabled a:focus{background:#fff;color:#bfbfbf}.pure-menu .pure-menu-disabled>a{background-image:none;border-color:transparent;cursor:default}.pure-menu .pure-menu-disabled>a,.pure-menu .pure-menu-can-have-children.pure-menu-disabled>a:after{color:#bfbfbf}.pure-menu .pure-menu-heading{color:#565d64;text-transform:uppercase;font-size:90%;margin-top:.5em;border-bottom-width:1px;border-bottom-style:solid;border-bottom-color:#dfdfdf}.pure-menu .pure-menu-selected a{color:#000}.pure-menu.pure-menu-open.pure-menu-fixed{border:0;border-bottom:1px solid #b7b7b7}.pure-paginator{letter-spacing:-.31em;*letter-spacing:normal;*word-spacing:-.43em;text-rendering:optimizespeed;list-style:none;margin:0;padding:0}.opera-only :-o-prefocus,.pure-paginator{word-spacing:-.43em}.pure-paginator li{display:inline-block;*display:inline;zoom:1;letter-spacing:normal;word-spacing:normal;vertical-align:top;text-rendering:auto}.pure-paginator .pure-button{border-radius:0;padding:.8em 1.4em;vertical-align:top;height:1.1em}.pure-paginator .pure-button:focus,.pure-paginator .pure-button:active{outline-style:none}.pure-paginator .prev,.pure-paginator .next{color:#C0C1C3;text-shadow:0 -1px 0 rgba(0,0,0,.45)}.pure-paginator .prev{border-radius:2px 0 0 2px}.pure-paginator .next{border-radius:0 2px 2px 0}@media (max-width:480px){.pure-menu-horizontal{width:100%}.pure-menu-children li{display:block;border-bottom:1px solid #000}} 16 | .pure-table{border-collapse:collapse;border-spacing:0;empty-cells:show;border:1px solid #cbcbcb}.pure-table caption{color:#000;font:italic 85%/1 arial,sans-serif;padding:1em 0;text-align:center}.pure-table td,.pure-table th{border-left:1px solid #cbcbcb;border-width:0 0 0 1px;font-size:inherit;margin:0;overflow:visible;padding:6px 12px}.pure-table td:first-child,.pure-table th:first-child{border-left-width:0}.pure-table thead{background:#e0e0e0;color:#000;text-align:left;vertical-align:bottom}.pure-table td{background-color:transparent}.pure-table-odd td{background-color:#f2f2f2}.pure-table-striped tr:nth-child(2n-1) td{background-color:#f2f2f2}.pure-table-bordered td{border-bottom:1px solid #cbcbcb}.pure-table-bordered tbody>tr:last-child td,.pure-table-horizontal tbody>tr:last-child td{border-bottom-width:0}.pure-table-horizontal td,.pure-table-horizontal th{border-width:0 0 1px;border-bottom:1px solid #cbcbcb}.pure-table-horizontal tbody>tr:last-child td{border-bottom-width:0} -------------------------------------------------------------------------------- /pkg/legacy/web/templates/cache.html: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/docker/gordon/e3048ba690fbac0fd032dcd9382b7dd326c09bbc/pkg/legacy/web/templates/cache.html -------------------------------------------------------------------------------- /pkg/legacy/web/templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | Docker Hub 17 | 107 | 108 | 109 | 110 | 111 | 112 |
113 | {% include 'nav.html' %} 114 | 115 |
116 |
117 |
118 |
119 |

This week

120 |
121 |
122 |
123 |
124 |

20 Issues Opened (20)

125 |
126 |
127 |
128 |
129 |

20 Pulls Opened (+15%)

130 |
131 |
132 |
133 |
134 |

201 Pulls Merged (-15%)

135 |
136 |
137 |
138 |
139 |

{{ issue_closed_collection.this_week_count }} Issues Closed ({{ issue_closed_collection.difference }}%)

140 |
141 |
142 |
143 |
144 |
145 |
146 |

Dusty Issues

147 | {% for issue in oldest_issues %} 148 |

149 | {{ issue.age }}d{{ issue.title }} (#{{ issue.number }}) 150 | 151 |

152 | {% endfor %} 153 |
154 |
155 | 156 |
157 |
158 |

Issues That Need ♡

159 | {% for issue in attention_issues %} 160 |

161 | {{ issue.age }}d{{ issue.title }} (#{{ issue.number }}) 162 |

163 | {% endfor %} 164 |
165 |
166 | 167 |
168 |
169 |

Dusty Pull Requests

170 | {% for pull in oldest_pulls %} 171 |

172 | {{ pull.age }}d{{ pull.title }} (#{{ pull.number}}) 173 |

174 | {% endfor %} 175 |
176 |
177 | 178 |
179 |
180 |

Pull Requests That Need ♡

181 | {% for pull in attention_pulls %} 182 |

183 | {{ pull.age }}d{{ pull.title }} (#{{pull.number}}) 184 |

185 | {% endfor %} 186 | 187 |
188 |
189 |
190 |
191 | 192 | 195 |
196 | 197 | 209 | 210 | 211 | 212 | -------------------------------------------------------------------------------- /pkg/legacy/web/templates/nav.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | Docker Bot 4 | 5 | 11 |
12 |
13 | -------------------------------------------------------------------------------- /pkg/legacy/web/views.py: -------------------------------------------------------------------------------- 1 | from flask import render_template 2 | from flask import jsonify 3 | from flask import request 4 | from controller import IssueController as IC 5 | from controller import IssueCollectionController as ICC 6 | from controller import ApiController 7 | from api.router import route_and_handle 8 | from adapters.git import get_lead_maintainer 9 | from adapters.git import get_all_maintainers 10 | 11 | import logging 12 | log = logging.getLogger('views') 13 | 14 | 15 | def index(): 16 | c = None 17 | ic = IC() 18 | icc = ICC() 19 | return render_template('index.html', 20 | oldest_issues = ic.get_oldest_issues(), 21 | oldest_pulls = ic.get_oldest_pulls(), 22 | attention_issues = ic.get_least_issues(), 23 | attention_pulls = ic.get_least_pulls(), 24 | top_contributors = ic.get_top_contributors(), 25 | issue_open_collection = icc.get_issues_opened_count(), 26 | issue_closed_collection = icc.get_issues_closed_count(), 27 | cache = c, 28 | ) 29 | 30 | def hook(): 31 | api = ApiController() 32 | data = api.route(request.headers, request.json) 33 | return jsonify(success="success") 34 | 35 | 36 | def lead_maintainer(issue): 37 | return jsonify(maintainer=get_lead_maintainer(int(issue))) 38 | 39 | def maintainers(issue): 40 | return jsonify(maintainers=get_all_maintainers(int(issue))) 41 | 42 | def robot(): 43 | return render_template("robot.html") 44 | --------------------------------------------------------------------------------