├── README.md └── main.go /README.md: -------------------------------------------------------------------------------- 1 | # prbot 2 | 3 | This is a program that automatically generates pull requests. 4 | 5 | It is limited in scope right now: it looks for Go files that need gofmt'ing, 6 | and makes a pull request to fix that up. 7 | 8 | ## Authentication 9 | 10 | Visit https://github.com/settings/tokens and create a personal access token, 11 | while logged in to GitHub as the user as whom you wish to make pull requests. 12 | Make sure it has the `repo:public_repo` scope. 13 | 14 | Store the token in `$HOME/.prbot-token` and chmod 600 that file. 15 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | /* 2 | prbot is a program that finds problems to fix on GitHub 3 | and automatically makes pull requests for them. 4 | */ 5 | package main 6 | 7 | import ( 8 | "bytes" 9 | "flag" 10 | "fmt" 11 | "go/format" 12 | "io/ioutil" 13 | "log" 14 | "os" 15 | "path/filepath" 16 | "strings" 17 | "sync" 18 | 19 | "github.com/google/go-github/github" 20 | "golang.org/x/net/context" 21 | "golang.org/x/oauth2" 22 | ) 23 | 24 | func usage() { 25 | fmt.Fprintf(os.Stderr, "usage: prbot \n") 26 | flag.PrintDefaults() 27 | } 28 | 29 | func main() { 30 | flag.Usage = usage 31 | flag.Parse() 32 | 33 | if flag.NArg() != 1 { 34 | usage() 35 | os.Exit(1) 36 | } 37 | parts := strings.Split(flag.Arg(0), "/") 38 | if len(parts) != 2 { 39 | usage() 40 | os.Exit(1) 41 | } 42 | owner, repo := parts[0], parts[1] 43 | 44 | tokenFile := filepath.Join(os.Getenv("HOME"), ".prbot-token") 45 | tokenData, err := ioutil.ReadFile(tokenFile) 46 | if err != nil { 47 | log.Fatalf("Reading auth token: %v", err) 48 | } 49 | ts := oauth2.StaticTokenSource(&oauth2.Token{ 50 | AccessToken: string(tokenData), 51 | }) 52 | tc := oauth2.NewClient(context.Background(), ts) 53 | gh := github.NewClient(tc) 54 | gh.UserAgent = "prbot/0.1" 55 | 56 | const branch = "master" // TODO: flag for this 57 | 58 | log.Printf("Resolving branch %s in github.com/%s/%s ...", branch, owner, repo) 59 | ref, _, err := gh.Git.GetRef(owner, repo, "refs/heads/"+branch) 60 | if err != nil { 61 | log.Fatalf("Getting ref: %v", err) 62 | } 63 | if *ref.Object.Type != "commit" { 64 | log.Fatalf("branch %s does not point at a commit", branch) 65 | } 66 | origCommit := *ref.Object.SHA 67 | 68 | log.Printf("Fetching tree for github.com/%s/%s @ %s ...", owner, repo, origCommit) 69 | tree, _, err := gh.Git.GetTree(owner, repo, origCommit, true /* recursive */) 70 | if err != nil { 71 | log.Fatalf("Getting tree: %v", err) 72 | } 73 | log.Printf("Original tree with %d entries: %s ...", len(tree.Entries), *tree.SHA) 74 | var goFiles []github.TreeEntry 75 | for _, te := range tree.Entries { 76 | if *te.Type == "blob" && strings.HasSuffix(*te.Path, ".go") { 77 | // Safety measure; let's stick with files under 1 MB. 78 | if te.Size != nil && *te.Size > 1<<20 { 79 | log.Printf("Warning: Skipping %s because it is too big", *te.Path) 80 | continue 81 | } 82 | goFiles = append(goFiles, te) 83 | } 84 | } 85 | log.Printf("Found %d Go source files", len(goFiles)) 86 | 87 | // TODO: sensible rate limiting... 88 | 89 | var wg sync.WaitGroup 90 | var mu sync.Mutex 91 | var changes []github.TreeEntry 92 | add := func(base github.TreeEntry, newContents string) { 93 | mu.Lock() 94 | defer mu.Unlock() 95 | changes = append(changes, github.TreeEntry{ 96 | Path: base.Path, 97 | Mode: base.Mode, 98 | Type: base.Type, 99 | Content: github.String(newContents), 100 | }) 101 | } 102 | for _, te := range goFiles { 103 | te := te 104 | wg.Add(1) 105 | go func() { 106 | defer wg.Done() 107 | abbr := fmt.Sprintf("%s %.7s", *te.Path, *te.SHA) 108 | 109 | in, err := rawBlob(gh, owner, repo, *te.SHA) 110 | if err != nil { 111 | log.Printf("Fetching blob (%s): %v", abbr, err) 112 | return 113 | } 114 | out, err := format.Source(in) 115 | if err != nil { 116 | log.Printf("Bad Go source (%s): %v", abbr, err) 117 | log.Printf("%s\n", in) 118 | return 119 | } 120 | if bytes.Equal(in, out) { 121 | return 122 | } 123 | log.Printf("(%s) needs gofmt'ing!", abbr) 124 | add(te, string(out)) 125 | }() 126 | } 127 | wg.Wait() 128 | log.Printf("Found %d Go source files that need changes", len(changes)) 129 | if len(changes) == 0 { 130 | return 131 | } 132 | 133 | log.Printf("Creating fork ...") 134 | fork, _, err := gh.Repositories.CreateFork(owner, repo, nil) 135 | if err != nil { 136 | log.Fatalf("Creating fork: %v", err) 137 | } 138 | //log.Printf("Fork: %v", fork) 139 | log.Printf("Fork URL: %v", *fork.HTMLURL) 140 | // TODO: Do we need to poll until the fork is ready? 141 | 142 | log.Printf("Creating new tree ...") 143 | newTree, _, err := gh.Git.CreateTree(*fork.Owner.Login, *fork.Name, *tree.SHA, changes) 144 | if err != nil { 145 | log.Fatalf("Creating tree: %v", err) 146 | } 147 | log.Printf("New tree: %s", *newTree.SHA) 148 | 149 | log.Printf("Creating commit ...") 150 | comm, _, err := gh.Git.CreateCommit(*fork.Owner.Login, *fork.Name, &github.Commit{ 151 | Message: github.String("Run gofmt over Go source files."), 152 | Tree: &github.Tree{SHA: newTree.SHA}, 153 | Parents: []github.Commit{ 154 | {SHA: github.String(origCommit)}, 155 | }, 156 | }) 157 | if err != nil { 158 | log.Fatalf("Creating commit: %v", err) 159 | } 160 | log.Printf("Commit: %s", *comm.SHA) 161 | 162 | log.Printf("Creating branch ...") 163 | prBranch := "prbot-gofmt" 164 | ref, _, err = gh.Git.CreateRef(*fork.Owner.Login, *fork.Name, &github.Reference{ 165 | Ref: github.String("refs/heads/" + prBranch), 166 | Object: &github.GitObject{ 167 | Type: github.String("commit"), 168 | SHA: comm.SHA, 169 | }, 170 | }) 171 | if err != nil { 172 | log.Fatalf("Creating branch: %v", err) 173 | } 174 | //log.Printf("Branch: %v", ref) 175 | log.Printf("Branch URL: %s/tree/%s", *fork.HTMLURL, prBranch) 176 | 177 | log.Printf("Creating pull request ...") 178 | pr, _, err := gh.PullRequests.Create(owner, repo, &github.NewPullRequest{ 179 | Title: github.String("gofmt everything"), 180 | Head: github.String(*fork.Owner.Login + ":" + prBranch), 181 | Base: github.String(branch), 182 | Body: github.String("I ran gofmt over this repository using prbot, an automated tool."), 183 | }) 184 | if err != nil { 185 | log.Fatalf("Creating pull request: %v", err) 186 | } 187 | log.Printf("Pull request: %s", *pr.HTMLURL) 188 | } 189 | 190 | func rawBlob(gh *github.Client, owner, repo, sha1 string) ([]byte, error) { 191 | // gh.Git.GetBlob only permits getting the base64 version. 192 | u := fmt.Sprintf("repos/%v/%v/git/blobs/%v", owner, repo, sha1) 193 | req, err := gh.NewRequest("GET", u, nil) 194 | if err != nil { 195 | return nil, err 196 | } 197 | req.Header.Set("Accept", "application/vnd.github.v3.raw") 198 | 199 | var buf bytes.Buffer 200 | if _, err = gh.Do(req, &buf); err != nil { 201 | return nil, err 202 | } 203 | return buf.Bytes(), nil 204 | } 205 | --------------------------------------------------------------------------------