From cc975363a9254c4878cd9a20708daf523bb576cf Mon Sep 17 00:00:00 2001 From: Andrew Rynhard Date: Mon, 1 Jul 2019 22:59:49 -0700 Subject: [PATCH] feat: add support for GH actions on forked repo PRs (#130) Signed-off-by: Andrew Rynhard --- .drone.yml | 15 ++++ internal/enforcer/enforcer.go | 60 ++++----------- internal/git/git.go | 51 ++++++++++++- internal/summarizer/summarizer.go | 119 ++++++++++++++++++++++++++++++ 4 files changed, 199 insertions(+), 46 deletions(-) create mode 100644 internal/summarizer/summarizer.go diff --git a/.drone.yml b/.drone.yml index fa6a43da..6619deef 100644 --- a/.drone.yml +++ b/.drone.yml @@ -5,6 +5,7 @@ services: - name: docker image: docker:dind privileged: true + network_mode: host volumes: - name: dockersock path: /var/run @@ -66,6 +67,20 @@ steps: event: - push + - name: release + image: plugins/github-release + settings: + api_key: + from_secret: github_token + draft: true + files: + - build/conform-* + checksum: + - sha256 + - sha512 + when: + event: tag + volumes: - name: dockersock temp: {} diff --git a/internal/enforcer/enforcer.go b/internal/enforcer/enforcer.go index 3b0e54cc..711d4695 100644 --- a/internal/enforcer/enforcer.go +++ b/internal/enforcer/enforcer.go @@ -5,20 +5,16 @@ package enforcer import ( - "context" "fmt" "io/ioutil" "log" - "net/http" "os" - "path" - "strings" "text/tabwriter" "github.com/autonomy/conform/internal/policy" "github.com/autonomy/conform/internal/policy/commit" "github.com/autonomy/conform/internal/policy/license" - "github.com/google/go-github/github" + "github.com/autonomy/conform/internal/summarizer" "github.com/mitchellh/mapstructure" "github.com/pkg/errors" @@ -27,9 +23,8 @@ import ( // Conform is a struct that conform.yaml gets decoded into. type Conform struct { - Policies []*PolicyDeclaration `yaml:"policies"` - - token string + Policies []*PolicyDeclaration `yaml:"policies"` + summarizer summarizer.Summarizer } // PolicyDeclaration allows a user to declare an arbitrary type along with a @@ -60,7 +55,13 @@ func New() (*Conform, error) { token, ok := os.LookupEnv("GITHUB_TOKEN") if ok { - c.token = token + s, err := summarizer.NewGitHubSummarizer(token) + if err != nil { + return nil, err + } + c.summarizer = s + } else { + c.summarizer = &summarizer.Noop{} } return c, nil @@ -85,10 +86,14 @@ func (c *Conform) Enforce(setters ...policy.Option) { for _, err := range check.Errors() { fmt.Fprintf(w, "%s\t%s\t%s\t%v\t\n", p.Type, check.Name(), "FAILED", err) } - c.SetStatus("failure", p.Type, check.Name(), check.Message()) + if err := c.summarizer.SetStatus("failure", p.Type, check.Name(), check.Message()); err != nil { + log.Printf("WARNING: summary failed: %+v", err) + } } else { fmt.Fprintf(w, "%s\t%s\t%s\t%s\t\n", p.Type, check.Name(), "PASS", "") - c.SetStatus("success", p.Type, check.Name(), check.Message()) + if err := c.summarizer.SetStatus("success", p.Type, check.Name(), check.Message()); err != nil { + log.Printf("WARNING: summary failed: %+v", err) + } } } } @@ -101,39 +106,6 @@ func (c *Conform) Enforce(setters ...policy.Option) { } } -// SetStatus sets the status of a GitHub check. -// Valid statuses are "error", "failure", "pending", "success" -func (c *Conform) SetStatus(state, policy, check, message string) { - if c.token == "" { - return - } - statusCheckContext := strings.ReplaceAll(strings.ToLower(path.Join("conform", policy, check)), " ", "-") - description := message - repoStatus := &github.RepoStatus{} - repoStatus.Context = &statusCheckContext - repoStatus.Description = &description - repoStatus.State = &state - - http.DefaultClient.Transport = roundTripper{c.token} - githubClient := github.NewClient(http.DefaultClient) - - parts := strings.Split(os.Getenv("GITHUB_REPOSITORY"), "/") - - _, _, err := githubClient.Repositories.CreateStatus(context.Background(), parts[0], parts[1], os.Getenv("GITHUB_SHA"), repoStatus) - if err != nil { - log.Fatal(err) - } -} - -type roundTripper struct { - accessToken string -} - -func (rt roundTripper) RoundTrip(r *http.Request) (*http.Response, error) { - r.Header.Set("Authorization", fmt.Sprintf("Bearer %s", rt.accessToken)) - return http.DefaultTransport.RoundTrip(r) -} - func (c *Conform) enforce(declaration *PolicyDeclaration, opts *policy.Options) (*policy.Report, error) { if _, ok := policyMap[declaration.Type]; !ok { return nil, errors.Errorf("Policy %q is not defined", declaration.Type) diff --git a/internal/git/git.go b/internal/git/git.go index 87d4999f..e8f503a2 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -5,11 +5,14 @@ package git import ( + "fmt" "os" "path" "path/filepath" git "gopkg.in/src-d/go-git.v4" + "gopkg.in/src-d/go-git.v4/config" + "gopkg.in/src-d/go-git.v4/plumbing" "gopkg.in/src-d/go-git.v4/plumbing/object" ) @@ -76,14 +79,58 @@ func (g *Git) Message() (message string, err error) { func (g *Git) HasGPGSignature() (ok bool, err error) { ref, err := g.repo.Head() if err != nil { - return + return false, err } commit, err := g.repo.CommitObject(ref.Hash()) if err != nil { - return + return false, err } ok = commit.PGPSignature != "" return ok, err } + +// FetchPullRequest fetches a remote PR. +func (g *Git) FetchPullRequest(remote string, number int) (err error) { + opts := &git.FetchOptions{ + RemoteName: remote, + RefSpecs: []config.RefSpec{ + config.RefSpec(fmt.Sprintf("refs/pull/%d/head:pr/%d", number, number)), + }, + } + if err = g.repo.Fetch(opts); err != nil { + return err + } + + return nil +} + +// CheckoutPullRequest checks out pull request. +func (g *Git) CheckoutPullRequest(number int) (err error) { + w, err := g.repo.Worktree() + if err != nil { + return err + } + + opts := &git.CheckoutOptions{ + Branch: plumbing.ReferenceName(fmt.Sprintf("pr/%d", number)), + } + + if err := w.Checkout(opts); err != nil { + return err + } + + return nil +} + +// SHA returns the sha of the current commit. +func (g *Git) SHA() (sha string, err error) { + ref, err := g.repo.Head() + if err != nil { + return sha, err + } + sha = ref.Hash().String() + + return sha, nil +} diff --git a/internal/summarizer/summarizer.go b/internal/summarizer/summarizer.go new file mode 100644 index 00000000..11c13206 --- /dev/null +++ b/internal/summarizer/summarizer.go @@ -0,0 +1,119 @@ +package summarizer + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io/ioutil" + "log" + "net/http" + "os" + "path" + "strings" + + "github.com/autonomy/conform/internal/git" + "github.com/google/go-github/github" +) + +// Summarizer describes a hook for send summarized results to a remote API. +type Summarizer interface { + SetStatus(string, string, string, string) error +} + +// GitHub is a summarizer that can be used with GitHub. +type GitHub struct { + token string + owner string + repo string + sha string +} + +// Noop is a summarizer that does nothing. +type Noop struct { +} + +// SetStatus is a noop func. +func (n *Noop) SetStatus(state, policy, check, message string) error { + return nil +} + +// NewGitHubSummarizer returns a summarizer that posts policy checks as status +// checks on a pull request. +func NewGitHubSummarizer(token string) (*GitHub, error) { + eventPath, ok := os.LookupEnv("GITHUB_EVENT_PATH") + if !ok { + return nil, errors.New("GITHUB_EVENT_PATH is not set") + } + + data, err := ioutil.ReadFile(eventPath) + if err != nil { + return nil, err + } + + pullRequestEvent := &github.PullRequestEvent{} + if err = json.Unmarshal(data, pullRequestEvent); err != nil { + return nil, err + } + + g, err := git.NewGit() + if err != nil { + return nil, err + } + + if err = g.FetchPullRequest("origin", pullRequestEvent.GetNumber()); err != nil { + return nil, err + } + + if err = g.CheckoutPullRequest(pullRequestEvent.GetNumber()); err != nil { + return nil, err + } + + sha, err := g.SHA() + if err != nil { + log.Fatal(err) + } + + gh := &GitHub{ + token: token, + owner: pullRequestEvent.GetRepo().GetOwner().GetLogin(), + repo: pullRequestEvent.GetRepo().GetName(), + sha: sha, + } + + return gh, nil +} + +// SetStatus sets the status of a GitHub check. +// Valid statuses are "error", "failure", "pending", "success" +func (gh *GitHub) SetStatus(state, policy, check, message string) error { + if gh.token == "" { + return errors.New("no token") + } + statusCheckContext := strings.ReplaceAll(strings.ToLower(path.Join("conform", policy, check)), " ", "-") + description := message + repoStatus := &github.RepoStatus{} + repoStatus.Context = &statusCheckContext + repoStatus.Description = &description + repoStatus.State = &state + + http.DefaultClient.Transport = roundTripper{gh.token} + githubClient := github.NewClient(http.DefaultClient) + + _, _, err := githubClient.Repositories.CreateStatus(context.Background(), gh.owner, gh.repo, gh.sha, repoStatus) + if err != nil { + return err + } + + return nil +} + +type roundTripper struct { + accessToken string +} + +// RoundTrip implements the net/http.RoundTripper interface. +func (rt roundTripper) RoundTrip(r *http.Request) (*http.Response, error) { + r.Header.Set("Authorization", fmt.Sprintf("Bearer %s", rt.accessToken)) + return http.DefaultTransport.RoundTrip(r) +}