Skip to content

Commit

Permalink
testutil: implement merge-pr action (#356)
Browse files Browse the repository at this point in the history
Implements a simple PR merge action that:
 - Is triggered when a label is added to the PR
 - Checks if all actions/checks have passed
 - The `merge when ready` is applied.
 - Then merges the PR using our guidelines.

category: feature 
ticket: #325
  • Loading branch information
corverroos authored Apr 5, 2022
1 parent fb5591c commit 2077b8f
Show file tree
Hide file tree
Showing 6 changed files with 676 additions and 0 deletions.
17 changes: 17 additions & 0 deletions .github/workflows/merge-pr.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
name: merge-pr
on:
pull_request:
types: [labeled]

jobs:
merge-pr:
runs-on: ubuntu-latest
env:
GITHUB_PR: ${{ toJSON(github.event.pull_request) }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- uses: actions/checkout@v2
- uses: actions/setup-go@v2
with:
go-version: '^1.17.1'
- run: go run testutil/mergepr/mergepr.go
4 changes: 4 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ require (
github.com/coinbase/kryptology v1.5.6-0.20220316191335-269410e1b06b
github.com/ethereum/go-ethereum v1.10.16
github.com/ferranbt/fastssz v0.0.0-20220103083642-bc5fefefa28b
github.com/google/go-github/v43 v43.0.0
github.com/gorilla/mux v1.8.0
github.com/jonboulle/clockwork v0.2.3
github.com/jsternberg/zap-logfmt v1.2.0
Expand All @@ -32,6 +33,7 @@ require (
go.opentelemetry.io/otel/trace v1.6.1
go.uber.org/automaxprocs v1.4.0
go.uber.org/zap v1.21.0
golang.org/x/oauth2 v0.0.0-20220309155454-6242fa91716a
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c
golang.org/x/tools v0.1.10
)
Expand Down Expand Up @@ -65,6 +67,7 @@ require (
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang/protobuf v1.5.2 // indirect
github.com/golang/snappy v0.0.4 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/google/gopacket v1.1.19 // indirect
github.com/gorilla/websocket v1.4.2 // indirect
github.com/hashicorp/golang-lru v0.5.5-0.20210104140557-80c98217689d // indirect
Expand Down Expand Up @@ -162,6 +165,7 @@ require (
golang.org/x/sys v0.0.0-20220227234510-4e6760a101f9 // indirect
golang.org/x/text v0.3.7 // indirect
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/grpc v1.44.0 // indirect
google.golang.org/protobuf v1.27.1 // indirect
gopkg.in/cenkalti/backoff.v1 v1.1.0 // indirect
Expand Down
11 changes: 11 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kB
github.com/bmizerany/pat v0.0.0-20170815010413-6226ea591a40/go.mod h1:8rLXio+WjiTceGBHIoTvn60HIbs7Hm7bcHjyrSqYB9c=
github.com/boltdb/bolt v1.3.1/go.mod h1:clJnj/oiGkjum5o1McbSZDSLxVThjynRyGBgiAx27Ps=
github.com/bradfitz/go-smtpd v0.0.0-20170404230938-deb6d6237625/go.mod h1:HYsPBTaaSFSlLx/70C2HPIMNZpVV8+vt/A+FMnYP11g=
github.com/bradleyfalzon/ghinstallation/v2 v2.0.4/go.mod h1:B40qPqJxWE0jDZgOR1JmaMy+4AY1eBP+IByOvqyAKp0=
github.com/btcsuite/btcd v0.0.0-20190523000118-16327141da8c/go.mod h1:3J08xEfcugPacsc34/LKRU2yO7YmuT8yt28J8k2+rrI=
github.com/btcsuite/btcd v0.20.1-beta/go.mod h1:wVuoA8VJLEcwgqHBwHmzLRazpKxTv13Px/pDuV7OomQ=
github.com/btcsuite/btcd v0.21.0-beta/go.mod h1:ZSWyehm27aAuS9bvkATT+Xte3hjHZ+MRgMY/8NJ7K94=
Expand Down Expand Up @@ -338,6 +339,7 @@ github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zV
github.com/gogo/protobuf v1.3.1/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt/v4 v4.0.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
github.com/golang/geo v0.0.0-20190916061304-5b978397cfec/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
Expand Down Expand Up @@ -397,8 +399,14 @@ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.7 h1:81/ik6ipDQS2aGcBfIN5dHDB36BwrStyeAQquSYCV4o=
github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE=
github.com/google/go-github v17.0.0+incompatible h1:N0LgJ1j65A7kfXrZnUDaYCs/Sf4rEjNlfyDHW9dolSY=
github.com/google/go-github v17.0.0+incompatible/go.mod h1:zLgOLi98H3fifZn+44m+umXrS52loVEgC2AApnigrVQ=
github.com/google/go-github/v41 v41.0.0/go.mod h1:XgmCA5H323A9rtgExdTcnDkcqp6S30AVACCBDOonIxg=
github.com/google/go-github/v43 v43.0.0 h1:y+GL7LIsAIF2NZlJ46ZoC/D1W1ivZasT0lnWHMYPZ+U=
github.com/google/go-github/v43 v43.0.0/go.mod h1:ZkTvvmCXBvsfPpTHXnH/d2hP9Y0cTbvN9kr5xqyXOIc=
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=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/gofuzz v1.1.1-0.20200604201612-c04b05f3adfa/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/gopacket v1.1.17/go.mod h1:UdDNZ1OO62aGYVnPhxT1U6aI7ukYtA/kB8vaU0diBUM=
Expand Down Expand Up @@ -1392,6 +1400,8 @@ golang.org/x/oauth2 v0.0.0-20210805134026-6f1e6394065a/go.mod h1:KelEdhl1UZF7XfJ
golang.org/x/oauth2 v0.0.0-20210819190943-2bc19b11175f/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20211005180243-6b3c2da341f1/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20220309155454-6242fa91716a h1:qfl7ob3DIEs3Ml9oLuPwY2N04gymzAW04WsUQHIClgM=
golang.org/x/oauth2 v0.0.0-20220309155454-6242fa91716a/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc=
golang.org/x/perf v0.0.0-20180704124530-6e6d33e29852/go.mod h1:JLpeXjPJfIyPr5TlbXLkXWLhP8nz10XfvxElABhCtcw=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
Expand Down Expand Up @@ -1660,6 +1670,7 @@ google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7
google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20180831171423-11092d34479b/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
Expand Down
257 changes: 257 additions & 0 deletions testutil/mergepr/mergepr.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,257 @@
// Copyright © 2021 Obol Technologies Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

//nolint:revive // Nested structs are ok since read-only.
package main

import (
"context"
"encoding/json"
"os"
"strings"

"github.com/google/go-github/v43/github"
"golang.org/x/oauth2"

"github.com/obolnetwork/charon/app/errors"
"github.com/obolnetwork/charon/app/log"
"github.com/obolnetwork/charon/app/z"
)

const (
DoNotMerge = "do not merge"
WIP = "wip"
MergeWhenReady = "merge when ready"
)

func main() {
ctx := context.Background()
if err := run(ctx); err != nil {
log.Error(ctx, "Run error", err)
os.Exit(1)
}
}

func run(ctx context.Context) error {
pr, err := getPRFromEnv()
if err != nil {
return err
}

t, err := getTokenFromEnv()
if err != nil {
return err
}

client := github.NewClient(oauth2.NewClient(ctx, t))

return attemptMerge(ctx, client, pr)
}

func getTokenFromEnv() (token, error) {
const tokenEnv = "GITHUB_TOKEN" //nolint:gosec

t, ok := os.LookupEnv(tokenEnv)
if !ok {
return "", errors.New("environments variable not set", z.Str("var", tokenEnv))
} else if strings.TrimSpace(t) == "" {
return "", errors.New("environments variable empty", z.Str("var", tokenEnv))
}

return token(t), nil
}

func getPRFromEnv() (PR, error) {
const prenv = "GITHUB_PR"

prJSON, ok := os.LookupEnv(prenv)
if !ok {
return PR{}, errors.New("environments variable unset", z.Str("var", prenv))
} else if strings.TrimSpace(prJSON) == "" {
return PR{}, errors.New("environments variable empty", z.Str("var", prenv))
}

var pr PR
err := json.Unmarshal([]byte(prJSON), &pr)
if err != nil {
return PR{}, errors.Wrap(err, "unmarshal pr")
}

return pr, nil
}

type PR struct {
Base struct {
Repo struct {
Name string
Owner struct {
Login string `json:"login"`
} `json:"owner"`
} `json:"repo"`
} `json:"base"`
Number int `json:"number"`
Head struct {
SHA string `json:"sha"`
} `json:"head"`
Labels []struct {
Name string `json:"name"`
} `json:"labels"`
Body string `json:"body"`
Title string `json:"title"`
State string `json:"state"`
Mergeable bool `json:"mergeable"`
Merged bool `json:"merged"`
}

func (pr PR) Owner() string {
return pr.Base.Repo.Owner.Login
}

func (pr PR) Repo() string {
return pr.Base.Repo.Name
}

func attemptMerge(ctx context.Context, client *github.Client, pr PR) error {
if pr.State != "open" {
log.Warn(ctx, "PR not open")
return nil
} else if pr.Merged {
log.Warn(ctx, "PR already merged")
return nil
} else if !pr.Mergeable {
log.Warn(ctx, "PR not mergeable")
return nil
}

if ok, err := allChecksPassed(ctx, client, pr); err != nil {
return err
} else if !ok {
return nil
}

log.Info(ctx, "All checks have passed")

if !readyToMerge(ctx, pr) {
return nil
}

log.Info(ctx, "Ready to merge")

return merge(ctx, client, pr)
}

func merge(ctx context.Context, client *github.Client, pr PR) error {
opts := &github.PullRequestOptions{
MergeMethod: "squash",
}

res, _, err := client.PullRequests.Merge(ctx, pr.Owner(), pr.Repo(), pr.Number, pr.Body, opts)
if err != nil {
return errors.Wrap(err, "merge")
}

if !res.GetMerged() {
log.Warn(ctx, "Merging failed", z.Str("msg", res.GetMessage()))
return nil
}

log.Info(ctx, "Merged PR", z.Str("sha", res.GetSHA()))

return nil
}

func readyToMerge(ctx context.Context, pr PR) bool {
body := strings.ToLower(pr.Body)
if strings.Contains(body, WIP) || strings.Contains(body, DoNotMerge) {
log.Warn(ctx, "Body contains 'wip' or 'do not merge'")
return false
}

var ready bool
for _, label := range pr.Labels {
if label.Name == WIP || label.Name == DoNotMerge {
log.Warn(ctx, "Labels contains 'wip' or 'do not merge'")
return false
}
if label.Name == MergeWhenReady {
ready = true
}
}

if !ready {
log.Warn(ctx, "Labels do not contain 'merge when ready'")
return false
}

return true
}

func allChecksPassed(ctx context.Context, client *github.Client, pr PR) (bool, error) {
var notOKChecks []string

sl, _, err := client.Repositories.GetCombinedStatus(ctx, pr.Owner(), pr.Repo(), pr.Head.SHA, nil)
if err != nil {
return false, errors.Wrap(err, "get combined status")
}

for _, s := range sl.Statuses {
if s.GetState() != "success" {
log.Warn(ctx, "Failed status detected",
z.Str("context", s.GetContext()),
z.Str("state", s.GetState()),
)
notOKChecks = append(notOKChecks, s.GetContext())
}
}

okConclusions := map[string]bool{
"success": true,
"neutral": true,
"skipped": true,
}

checkRuns, _, err := client.Checks.ListCheckRunsForRef(ctx, pr.Owner(), pr.Repo(), pr.Head.SHA, nil)
if err != nil {
return false, errors.Wrap(err, "list check runs")
}

for _, check := range checkRuns.CheckRuns {
if check.GetName() == "merge-pr" {
// Skip our selves
continue
}
if !okConclusions[check.GetConclusion()] {
notOKChecks = append(notOKChecks, check.GetName())
log.Warn(ctx, "Non-ok check detected",
z.Str("name", check.GetName()),
z.Str("conclusion", check.GetConclusion()),
)
}
}

if len(notOKChecks) > 0 {
return false, nil
}

return true, nil
}

type token string

func (t token) Token() (*oauth2.Token, error) {
return &oauth2.Token{
AccessToken: string(t),
TokenType: "Bearer",
}, nil
}
37 changes: 37 additions & 0 deletions testutil/mergepr/mergepr_internal_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// Copyright © 2021 Obol Technologies Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package main

import (
"context"
"os"
"testing"

"github.com/stretchr/testify/require"
)

// TestIntegration tests merge pr action if a GITHUB_TOKEN env var is found.
func TestIntegration(t *testing.T) {
if _, ok := os.LookupEnv("GITHUB_TOKEN"); !ok {
return
}

b, err := os.ReadFile("testdata/pr.json")
require.NoError(t, err)
require.NoError(t, os.Setenv("GITHUB_PR", string(b)))

err = run(context.Background())
require.NoError(t, err)
}
Loading

0 comments on commit 2077b8f

Please sign in to comment.