-
Notifications
You must be signed in to change notification settings - Fork 7
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Initial version of a ToBeReviewed pester-bot.
This bot is expected to be run periodically as a GitHub Action using the identity of a GitHub App. It will check recently submitted Pull Requests for any which went in ToBeReviewed (i.e. without any Approval on the PR). It will file an issue to followup. Signed-off-by: Denton Gentry <dgentry@tailscale.com>
- Loading branch information
0 parents
commit 70b3a8f
Showing
6 changed files
with
288 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,23 @@ | ||
name: To-Be-Reviewed Bot | ||
permissions: read-all | ||
|
||
on: | ||
schedule: | ||
- cron: '*/30 * * * *' | ||
|
||
jobs: | ||
bot: | ||
runs-on: ubuntu-latest | ||
steps: | ||
- name: Checkout code | ||
uses: actions/checkout@v2 | ||
|
||
- name: build-a-bot | ||
run: go build ./... | ||
|
||
- name: run-a-bot | ||
run: ./tbr-audit --org="tailscale" --bugrepo="ToBeReviewedBot" --repos="corp,tailscale" | ||
env: | ||
GH_APP_ID: ${{ secrets.GH_APP_ID }} | ||
GH_APP_INSTALL_ID: ${{ secrets.GH_APP_INSTALL_ID }} | ||
GH_APP_PRIVATE_KEY: ${{ secrets.GH_APP_PRIVATE_KEY }} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
./tbr-audit |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
This bot is expected to be run periodically as a GitHub Action | ||
using the identity of a GitHub App. It will check recently submitted | ||
Pull Requests for any which went in ToBeReviewed (i.e. without | ||
any Approval on the PR). | ||
|
||
It will file an issue to followup on the review. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,192 @@ | ||
package main | ||
|
||
import ( | ||
"context" | ||
"flag" | ||
"fmt" | ||
"log" | ||
"net/http" | ||
"os" | ||
"strconv" | ||
"strings" | ||
|
||
"github.com/bradleyfalzon/ghinstallation/v2" | ||
"github.com/google/go-github/v42/github" | ||
) | ||
|
||
var org string = "example-github-organization" | ||
|
||
// Return an HTTP client suitable to use with the GitHub API, initialized with | ||
// our API keys and certificate. | ||
// | ||
// This bot expects to run as an organization-level GitHub app, as seen in | ||
// https://github.com/organizations/<name>/settings/installations | ||
// This gives it permission to access private repos without using an individual's | ||
// Personal Access Token. | ||
func getGithubApiClient() *github.Client { | ||
app_id_string := os.Getenv("GH_APP_ID") | ||
app_install_string := os.Getenv("GH_APP_INSTALL_ID") | ||
key_string := os.Getenv("GH_APP_PRIVATE_KEY") | ||
|
||
if app_id_string == "" || app_install_string == "" || key_string == "" { | ||
log.Fatalf("GH_APP_ID, GH_APP_INSTALL_ID, and GH_APP_PRIVATE_KEY env variables must be set") | ||
} | ||
|
||
app_id, err := strconv.ParseInt(app_id_string, 10, 64) | ||
if err != nil { | ||
log.Fatal("Invalid GH_APP_ID environment variable, must be integer") | ||
} | ||
app_install, err := strconv.ParseInt(app_install_string, 10, 64) | ||
if err != nil { | ||
log.Fatal("Invalid GH_APP_INSTALL_ID environment variable, must be integer") | ||
} | ||
key := []byte(key_string) | ||
|
||
itr, err := ghinstallation.New(http.DefaultTransport, app_id, app_install, key) | ||
if err != nil { | ||
log.Fatal(err) | ||
} | ||
return github.NewClient(&http.Client{Transport: itr}) | ||
} | ||
|
||
// check whether an issue has already been filed for the given PR, and file one if not. | ||
func fileFollowupIssue(ctx context.Context, client *github.Client, repo, bugrepo string, prNum int) error { | ||
title := fmt.Sprintf("TBR %s/%s/pull/%d followup review", org, repo, prNum) | ||
|
||
// all of the followup issues are in corp, no matter the repo of the submitted PR | ||
check := fmt.Sprintf("%s in:title repo:%s/%s", title, org, bugrepo) | ||
followup, _, err := client.Search.Issues(ctx, check, &github.SearchOptions{ | ||
ListOptions: github.ListOptions{PerPage: 10}}) | ||
if err != nil { | ||
return err | ||
} | ||
|
||
if len(followup.Issues) > 0 { | ||
// Issue already filed, nothing more to do. | ||
return nil | ||
} | ||
|
||
body := fmt.Sprintf("https://github.com/%s/%s/pull/%d was filed to-be-reviewed "+ | ||
"without any reviewer approving. Someone needs to review it, followup on any "+ | ||
"changes needed, and note completion by closing this issue.", org, repo, prNum) | ||
req := github.IssueRequest{Title: &title, Body: &body} | ||
_, _, err = client.Issues.Create(ctx, org, bugrepo, &req) | ||
if err != nil { | ||
return err | ||
} | ||
|
||
return nil | ||
} | ||
|
||
func payloadOrDie(event *github.Event) interface{} { | ||
payload, err := event.ParsePayload() | ||
if err != nil { | ||
log.Fatalf("failed (%v) to parse: %v\n", err, event) | ||
} | ||
return payload | ||
} | ||
|
||
// The Activity API only includes State=APPROVED if the approval came within the last | ||
// 300 events. PRs approved a long time before submission will not show as APPROVED | ||
// in the PullRequestReviewEvent. | ||
// To double-check, we use the API to check all comments of a Pull Request if any of them | ||
// contained an Approval. | ||
func wasPrEverApproved(client *github.Client, repo string, prNum int) bool { | ||
opt := &github.ListOptions{PerPage: 100} | ||
ctx := context.Background() | ||
for { | ||
reviews, resp, err := client.PullRequests.ListReviews(ctx, org, repo, prNum, opt) | ||
if err != nil { | ||
log.Fatal(err.Error()) | ||
} | ||
|
||
for _, review := range reviews { | ||
if strings.EqualFold(*review.State, "approved") { | ||
return true | ||
} | ||
} | ||
|
||
if resp.NextPage == 0 { | ||
break | ||
} | ||
opt.Page = resp.NextPage | ||
} | ||
|
||
return false | ||
} | ||
|
||
func checkForToBeReviewed(client *github.Client, repo, bugrepo string) { | ||
type PullRequestState struct { | ||
Approved bool | ||
Submitted bool | ||
} | ||
pulls := make(map[int]*PullRequestState, 50) | ||
|
||
opt := &github.ListOptions{PerPage: 100} | ||
ctx := context.Background() | ||
for { | ||
events, resp, err := client.Activity.ListRepositoryEvents(ctx, org, repo, opt) | ||
if err != nil { | ||
log.Fatal(err.Error()) | ||
} | ||
|
||
for _, evt := range events { | ||
typ := evt.GetType() | ||
if typ == "PullRequestEvent" { | ||
payload := payloadOrDie(evt).(*github.PullRequestEvent) | ||
prNum := *payload.PullRequest.Number | ||
if _, ok := pulls[prNum]; !ok { | ||
pulls[prNum] = &PullRequestState{} | ||
} | ||
if strings.EqualFold(*payload.Action, "closed") && | ||
*payload.PullRequest.Merged { | ||
pulls[prNum].Submitted = true | ||
} | ||
} | ||
if typ == "PullRequestReviewEvent" { | ||
payload := payloadOrDie(evt).(*github.PullRequestReviewEvent) | ||
prNum := *payload.PullRequest.Number | ||
if _, ok := pulls[prNum]; !ok { | ||
pulls[prNum] = &PullRequestState{} | ||
} | ||
if strings.EqualFold(*payload.Review.State, "approved") { | ||
pulls[prNum].Approved = true | ||
} | ||
} | ||
} | ||
|
||
if resp.NextPage == 0 { | ||
break | ||
} | ||
opt.Page = resp.NextPage | ||
} | ||
|
||
for prNum, pr := range pulls { | ||
if pr.Submitted && !pr.Approved { | ||
// Double-check if there was ever an approval. | ||
if wasPrEverApproved(client, repo, prNum) { | ||
continue | ||
} | ||
|
||
// This Pull Request was submitted without an Approver. | ||
err := fileFollowupIssue(ctx, client, repo, bugrepo, prNum) | ||
if err != nil { | ||
log.Fatal(err) | ||
} | ||
} | ||
} | ||
} | ||
|
||
func main() { | ||
flag.StringVar(&org, "org", "example-github-organization", "GitHub organization to use") | ||
reposPtr := flag.String("repos", "example-github-repo", | ||
"comma-separated list of GitHub repositories to check for to-be-reviewed PRs") | ||
bugrepoPtr := flag.String("bugrepo", "ToBeReviewedBot", | ||
"name of repository to file followup issues in") | ||
flag.Parse() | ||
|
||
client := getGithubApiClient() | ||
for _, repo := range strings.Split(*reposPtr, ",") { | ||
checkForToBeReviewed(client, repo, *bugrepoPtr) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,22 @@ | ||
module github.com/tailscale/tbr-audit | ||
|
||
go 1.17 | ||
|
||
require ( | ||
github.com/google/go-github/v42 v42.0.0 | ||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be | ||
) | ||
|
||
require ( | ||
github.com/bradleyfalzon/ghinstallation v1.1.1 // indirect | ||
github.com/bradleyfalzon/ghinstallation/v2 v2.0.4 // indirect | ||
github.com/dgrijalva/jwt-go v3.2.0+incompatible // indirect | ||
github.com/golang-jwt/jwt/v4 v4.0.0 // indirect | ||
github.com/golang/protobuf v1.3.2 // indirect | ||
github.com/google/go-github/v29 v29.0.2 // indirect | ||
github.com/google/go-github/v41 v41.0.0 // indirect | ||
github.com/google/go-querystring v1.1.0 // indirect | ||
golang.org/x/crypto v0.0.0-20210817164053-32db794688a5 // indirect | ||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110 // indirect | ||
google.golang.org/appengine v1.6.7 // indirect | ||
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,44 @@ | ||
github.com/bradleyfalzon/ghinstallation v1.1.1 h1:pmBXkxgM1WeF8QYvDLT5kuQiHMcmf+X015GI0KM/E3I= | ||
github.com/bradleyfalzon/ghinstallation v1.1.1/go.mod h1:vyCmHTciHx/uuyN82Zc3rXN3X2KTK8nUTCrTMwAhcug= | ||
github.com/bradleyfalzon/ghinstallation/v2 v2.0.4 h1:tXKVfhE7FcSkhkv0UwkLvPDeZ4kz6OXd0PKPlFqf81M= | ||
github.com/bradleyfalzon/ghinstallation/v2 v2.0.4/go.mod h1:B40qPqJxWE0jDZgOR1JmaMy+4AY1eBP+IByOvqyAKp0= | ||
github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM= | ||
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= | ||
github.com/golang-jwt/jwt/v4 v4.0.0 h1:RAqyYixv1p7uEnocuy8P1nru5wprCh/MH2BIlW5z5/o= | ||
github.com/golang-jwt/jwt/v4 v4.0.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg= | ||
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= | ||
github.com/golang/protobuf v1.3.2 h1:6nsPYzhq5kReh6QImI3k5qWzO4PEbvbIW2cwSfR/6xs= | ||
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= | ||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= | ||
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= | ||
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= | ||
github.com/google/go-github/v29 v29.0.2 h1:opYN6Wc7DOz7Ku3Oh4l7prmkOMwEcQxpFtxdU8N8Pts= | ||
github.com/google/go-github/v29 v29.0.2/go.mod h1:CHKiKKPHJ0REzfwc14QMklvtHwCveD0PxlMjLlzAM5E= | ||
github.com/google/go-github/v41 v41.0.0 h1:HseJrM2JFf2vfiZJ8anY2hqBjdfY1Vlj/K27ueww4gg= | ||
github.com/google/go-github/v41 v41.0.0/go.mod h1:XgmCA5H323A9rtgExdTcnDkcqp6S30AVACCBDOonIxg= | ||
github.com/google/go-github/v42 v42.0.0 h1:YNT0FwjPrEysRkLIiKuEfSvBPCGKphW5aS5PxwaoLec= | ||
github.com/google/go-github/v42 v42.0.0/go.mod h1:jgg/jvyI0YlDOM1/ps6XYh04HNQ3vKf0CVko62/EhRg= | ||
github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= | ||
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= | ||
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= | ||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= | ||
golang.org/x/crypto v0.0.0-20210817164053-32db794688a5 h1:HWj/xjIHfjYU5nVXpTM0s39J9CbLn7Cc5a7IC5rwsMQ= | ||
golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= | ||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= | ||
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= | ||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110 h1:qWPm9rbaAMKs8Bq/9LRpbMqxWRVUAQwMI9fVrssnTfw= | ||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= | ||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be h1:vEDujvNQGv4jgYKudGeI/+DAX4Jffq6hpD55MmoEvKs= | ||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= | ||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= | ||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= | ||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= | ||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= | ||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= | ||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= | ||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= | ||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= | ||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= | ||
google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= | ||
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= |