From 3b164d95a3e465357034587920d37ed3def72e69 Mon Sep 17 00:00:00 2001 From: Adrian Orive Date: Mon, 9 Nov 2020 11:58:25 +0100 Subject: [PATCH] Define specific behavior for each PR action including synchronize Signed-off-by: Adrian Orive --- .github/workflows/main.yml | 2 +- verify/cmd/runner.go | 26 +-- verify/common.go | 345 +++++++++++++++++++++++++++++-------- 3 files changed, 282 insertions(+), 91 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index deef118..ae54e85 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -1,6 +1,6 @@ on: pull_request_target: - types: [opened, edited, reopened] + types: [opened, edited, reopened, synchronize] jobs: verify: diff --git a/verify/cmd/runner.go b/verify/cmd/runner.go index c6d48ad..32ccba0 100644 --- a/verify/cmd/runner.go +++ b/verify/cmd/runner.go @@ -18,8 +18,8 @@ package main import ( "fmt" - "strings" "regexp" + "strings" "github.com/google/go-github/v32/github" @@ -31,15 +31,17 @@ import ( type prErrs struct { errs []string } + func (e prErrs) Error() string { return fmt.Sprintf("%d issues found with your PR description", len(e.errs)) } + func (e prErrs) Help() string { res := make([]string, len(e.errs)) for _, err := range e.errs { parts := strings.Split(err, "\n") for i, part := range parts[1:] { - parts[i+1] = " "+part + parts[i+1] = " " + part } res = append(res, "- "+strings.Join(parts, "\n")) } @@ -49,23 +51,15 @@ func (e prErrs) Help() string { func main() { verify.ActionsEntrypoint(verify.RunPlugins( verify.PRPlugin{ - Name: "PR Type", + Name: "PR Type", Title: "PR Type in Title", ProcessPR: func(pr *github.PullRequest) (string, error) { return notesver.VerifyPRTitle(pr.GetTitle()) }, - ForAction: func(action string) bool { - switch action { - case "opened", "edited", "reopened": - return true - default: - return false - } - }, }, verify.PRPlugin{ - Name: "PR Desc", + Name: "PR Desc", Title: "Basic PR Descriptiveness Check", ProcessPR: func(pr *github.PullRequest) (string, error) { var errs []string @@ -92,14 +86,6 @@ func main() { } return "", prErrs{errs: errs} }, - ForAction: func(action string) bool { - switch action { - case "opened", "edited", "reopened": - return true - default: - return false - } - }, }, )) } diff --git a/verify/common.go b/verify/common.go index daa2d88..e0b0b78 100644 --- a/verify/common.go +++ b/verify/common.go @@ -17,14 +17,14 @@ limitations under the License. package verify import ( - "fmt" - "os" + "context" "encoding/json" "errors" - "context" - "time" + "fmt" + "os" "strings" "sync" + "time" "github.com/google/go-github/v32/github" "golang.org/x/oauth2" @@ -35,77 +35,211 @@ type ErrWithHelp interface { Help() string } +type logger struct{} + +func (logger) errorf(fmtStr string, args ...interface{}) { + fmt.Printf("::error::"+fmtStr+"\n", args...) +} + +func (logger) debugf(fmtStr string, args ...interface{}) { + fmt.Printf("::debug::"+fmtStr+"\n", args...) +} + +func (logger) warningf(fmtStr string, args ...interface{}) { + fmt.Printf("::warning::"+fmtStr+"\n", args...) +} + +var log logger + type PRPlugin struct { - ForAction func(string) bool ProcessPR func(pr *github.PullRequest) (string, error) - Name string - Title string + Name string + Title string + + logger } -func (p *PRPlugin) Entrypoint(env *ActionsEnv) error { - if p.ForAction != nil && !p.ForAction(env.Event.GetAction()) { - return nil +func (p PRPlugin) processPR(pr *github.PullRequest) (conclusion, summary, text string, err error) { + text, err = p.ProcessPR(pr) + + if err != nil { + conclusion = "failure" + summary = err.Error() + var helpErr ErrWithHelp + if errors.As(err, &helpErr) { + text = helpErr.Help() + } + } else { + conclusion = "success" + summary = "Success" } - repoParts := strings.Split(env.Event.GetRepo().GetFullName(), "/") - orgName, repoName := repoParts[0], repoParts[1] - - headSHA := env.Event.GetPullRequest().GetHead().GetSHA() - fmt.Printf("::debug::creating check run %q on %s/%s @ %s...\n", p.Name, orgName, repoName, headSHA) + // Log in case we can't submit the result for some reason + p.debugf("plugin conclusion: %q", conclusion) + p.debugf("plugin result summary: %q", summary) + p.debugf("plugin result details: %q", text) - resRun, runResp, err := env.Client.Checks.CreateCheckRun(context.TODO(), orgName, repoName, github.CreateCheckRunOptions{ - Name: p.Name, - HeadSHA: headSHA, - Status: github.String("in_progress"), - }) + return conclusion, summary, text, err +} + +func (p PRPlugin) createCheckRun(client *github.Client, owner, repo, headSHA string) (*github.CheckRun, error) { + p.debugf("creating check run %q on %s/%s @ %s...", p.Name, owner, repo, headSHA) + + checkRun, res, err := client.Checks.CreateCheckRun( + context.TODO(), + owner, + repo, + github.CreateCheckRunOptions{ + Name: p.Name, + HeadSHA: headSHA, + Status: github.String("in_progress"), + }, + ) if err != nil { - return fmt.Errorf("unable to submit check result: %w", err) + return nil, fmt.Errorf("unable to submit check result: %w", err) } - env.Debugf("create check API response: %+v", runResp) - env.Debugf("created run: %+v", resRun) + p.debugf("create check API response: %+v", res) + p.debugf("created run: %+v", checkRun) - successStatus, procErr := p.ProcessPR(env.Event.PullRequest) + return checkRun, nil +} - var summary, fullHelp, conclusion string - if procErr != nil { - summary = procErr.Error() - var helpErr ErrWithHelp - if errors.As(procErr, &helpErr) { - fullHelp = helpErr.Help() - } - conclusion = "failure" - } else { - summary = "Success" - fullHelp = successStatus - conclusion = "success" +func (p PRPlugin) getCheckRun(client *github.Client, owner, repo, headSHA string) (*github.CheckRun, error) { + p.debugf("getting check run %q on %s/%s @ %s...", p.Name, owner, repo, headSHA) + + checkRunList, res, err := client.Checks.ListCheckRunsForRef( + context.TODO(), + owner, + repo, + headSHA, + &github.ListCheckRunsOptions{ + CheckName: github.String(p.Name), + }, + ) + if err != nil { + return nil, err } - completedAt := github.Timestamp{Time: time.Now()} - // log in case we can't submit the result for some reason - env.Debugf("plugin result summary: %q", summary) - env.Debugf("plugin result details: %q", fullHelp) - env.Debugf("plugin conclusion: %q", conclusion) + p.debugf("list check API response: %+v", res) + p.debugf("listed runs: %+v", checkRunList) + + switch n := *checkRunList.Total; { + case n == 0: + return nil, fmt.Errorf("no instance of `%s` check run found on %s/%s @ %s", p.Name, owner, repo, headSHA) + case n == 1: + return checkRunList.CheckRuns[0], nil + case n > 1: + return nil, fmt.Errorf("multiple instances of `%s` check run found on %s/%s @ %s", p.Name, owner, repo, headSHA) + default: // Should never happen + return nil, fmt.Errorf("negative number of instances (%d) of `%s` check run found on %s/%s @ %s", n, p.Name, owner, repo, headSHA) + } +} + +func (p PRPlugin) resetCheckRun(client *github.Client, owner, repo string, checkRunID int64) error { + p.debugf("updating check run %q on %s/%s...", p.Name, owner, repo) + + checkRun, updateResp, err := client.Checks.UpdateCheckRun( + context.TODO(), + owner, + repo, + checkRunID, + github.UpdateCheckRunOptions{ + Name: p.Name, + Status: github.String("in-progress"), + }, + ) + if err != nil { + return fmt.Errorf("unable to update check result: %w", err) + } + + p.debugf("update check API response: %+v", updateResp) + p.debugf("updated run: %+v", checkRun) + + return nil +} + +func (p PRPlugin) rebindCheckRun(client *github.Client, owner, repo string, checkRunID int64, headSHA string) error { + p.debugf("updating check run %q on %s/%s...", p.Name, owner, repo) + + checkRun, updateResp, err := client.Checks.UpdateCheckRun( + context.TODO(), + owner, + repo, + checkRunID, + github.UpdateCheckRunOptions{ + Name: p.Name, + HeadSHA: github.String(headSHA), + }, + ) + if err != nil { + return fmt.Errorf("unable to update check result: %w", err) + } - resRun, updateResp, err := env.Client.Checks.UpdateCheckRun(context.TODO(), orgName, repoName, resRun.GetID(), github.UpdateCheckRunOptions{ - Name: p.Name, - Status: github.String("completed"), - Conclusion: github.String(conclusion), - CompletedAt: &completedAt, + p.debugf("update check API response: %+v", updateResp) + p.debugf("updated run: %+v", checkRun) + + return nil +} + +func (p PRPlugin) finishCheckRun(client *github.Client, owner, repo string, checkRunID int64, conclusion, summary, text string) error { + p.debugf("updating check run %q on %s/%s...", p.Name, owner, repo) + + checkRun, updateResp, err := client.Checks.UpdateCheckRun(context.TODO(), owner, repo, checkRunID, github.UpdateCheckRunOptions{ + Name: p.Name, + Conclusion: github.String(conclusion), + CompletedAt: &github.Timestamp{Time: time.Now()}, Output: &github.CheckRunOutput{ - Title: github.String(p.Title), + Title: github.String(p.Title), Summary: github.String(summary), - Text: github.String(fullHelp), + Text: github.String(text), }, }) if err != nil { return fmt.Errorf("unable to update check result: %w", err) } - env.Debugf("update check API response: %+v", updateResp) - env.Debugf("updated run: %+v", resRun) + p.debugf("update check API response: %+v", updateResp) + p.debugf("updated run: %+v", checkRun) + + return nil +} + +func (p *PRPlugin) Entrypoint(env *ActionsEnv) (err error) { + switch env.Event.GetAction() { + case "open": + err = p.onOpen(env) + case "reopen": + err = p.onReopen(env) + case "edit": + err = p.onEdit(env) + case "synchronize": + err = p.onSync(env) + default: + // Do nothing + } + + return +} + +func (p PRPlugin) onOpen(env *ActionsEnv) error { + headSHA := env.Event.GetPullRequest().GetHead().GetSHA() + + // Create the check run + checkRun, err := p.createCheckRun(env.Client, env.Owner, env.Repo, headSHA) + if err != nil { + return err + } - // return failure here too so that the whole suite fails (since the actions + // Process the PR + conclusion, summary, text, procErr := p.processPR(env.Event.PullRequest) + + // Update the check run + if err := p.finishCheckRun(env.Client, env.Owner, env.Repo, checkRun.GetID(), conclusion, summary, text); err != nil { + return err + } + + // Return failure here too so that the whole suite fails (since the actions // suite seems to ignore failing check runs when calculating general failure) if procErr != nil { return fmt.Errorf("failed: %v", procErr) @@ -113,18 +247,81 @@ func (p *PRPlugin) Entrypoint(env *ActionsEnv) error { return nil } -type ActionsEnv struct { - Event *github.PullRequestEvent - Client *github.Client +func (p PRPlugin) onReopen(env *ActionsEnv) error { + headSHA := env.Event.GetPullRequest().GetHead().GetSHA() + + // Get the check run + checkRun, err := p.getCheckRun(env.Client, env.Owner, env.Repo, headSHA) + if err != nil { + return err + } + + // Return failure here too so that the whole suite fails (since the actions + // suite seems to ignore failing check runs when calculating general failure) + if *checkRun.Conclusion == "failure" { + return fmt.Errorf("failed: %v", *checkRun.Output.Summary) + } + return nil } -func (ActionsEnv) Errorf(fmtStr string, args ...interface{}) { - fmt.Printf("::error::"+fmtStr+"\n", args...) + +func (p PRPlugin) onEdit(env *ActionsEnv) error { + headSHA := env.Event.GetPullRequest().GetHead().GetSHA() + + // Get the check run id + checkRun, err := p.getCheckRun(env.Client, env.Owner, env.Repo, headSHA) + if err != nil { + return err + } + checkRunID := checkRun.GetID() + + // Reset the check run + if err := p.resetCheckRun(env.Client, env.Owner, env.Repo, checkRunID); err != nil { + return err + } + + // Process the PR + conclusion, summary, text, procErr := p.processPR(env.Event.PullRequest) + + // Update the check run + if err := p.finishCheckRun(env.Client, env.Owner, env.Repo, checkRunID, conclusion, summary, text); err != nil { + return err + } + + // Return failure here too so that the whole suite fails (since the actions + // suite seems to ignore failing check runs when calculating general failure) + if procErr != nil { + return fmt.Errorf("failed: %v", procErr) + } + return nil } -func (ActionsEnv) Debugf(fmtStr string, args ...interface{}) { - fmt.Printf("::debug::"+fmtStr+"\n", args...) + +func (p PRPlugin) onSync(env *ActionsEnv) error { + before, after := env.Event.GetBefore(), env.Event.GetAfter() + + // Get the check run + checkRun, err := p.getCheckRun(env.Client, env.Owner, env.Repo, before) + if err != nil { + return err + } + + // Rebind the check run + if err := p.rebindCheckRun(env.Client, env.Owner, env.Repo, checkRun.GetID(), after); err != nil { + return err + } + + // Return failure here too so that the whole suite fails (since the actions + // suite seems to ignore failing check runs when calculating general failure) + if *checkRun.Conclusion == "failure" { + return fmt.Errorf("failed: %v", *checkRun.Output.Summary) + } + return nil } -func (ActionsEnv) Warnf(fmtStr string, args ...interface{}) { - fmt.Printf("::warning::"+fmtStr+"\n", args...) + +type ActionsEnv struct { + Owner string + Repo string + Event *github.PullRequestEvent + Client *github.Client } func SetupEnv() (*ActionsEnv, error) { @@ -132,21 +329,26 @@ func SetupEnv() (*ActionsEnv, error) { return nil, fmt.Errorf("not running in an action, bailing. Set GITHUB_ACTIONS and the other appropriate env vars if you really want to do this.") } + // Get owner and repository + ownerAndRepo := strings.Split(os.Getenv("GITHUB_REPOSITORY"), "/") + + // Get event path payloadPath := os.Getenv("GITHUB_EVENT_PATH") if payloadPath == "" { return nil, fmt.Errorf("no payload path set, something weird is up") } + // Parse the event payload, err := func() (github.PullRequestEvent, error) { payloadRaw, err := os.Open(payloadPath) if err != nil { return github.PullRequestEvent{}, fmt.Errorf("unable to load payload file: %w", err) } defer payloadRaw.Close() - + var payload github.PullRequestEvent if err := json.NewDecoder(payloadRaw).Decode(&payload); err != nil { - return payload, fmt.Errorf("unable to unmarshal payload: %w", err) + return payload, fmt.Errorf("unable to unmarshal payload: %w", err) } return payload, nil }() @@ -154,27 +356,30 @@ func SetupEnv() (*ActionsEnv, error) { return nil, err } - ctx := context.Background() - authClient := oauth2.NewClient(ctx, oauth2.StaticTokenSource( + // Create the client + client := github.NewClient(oauth2.NewClient(context.Background(), oauth2.StaticTokenSource( &oauth2.Token{AccessToken: os.Getenv("INPUT_GITHUB_TOKEN")}, - )) + ))) return &ActionsEnv{ - Event: &payload, - Client: github.NewClient(authClient), + Owner: ownerAndRepo[0], + Repo: ownerAndRepo[1], + Event: &payload, + Client: client, }, nil } type ActionsCallback func(*ActionsEnv) error + func ActionsEntrypoint(cb ActionsCallback) { env, err := SetupEnv() if err != nil { - env.Errorf("%v", err) + log.errorf("%v", err) os.Exit(1) } if err := cb(env); err != nil { - env.Errorf("%v", err) + log.errorf("%v", err) os.Exit(2) } fmt.Println("Success!") @@ -204,7 +409,7 @@ func RunPlugins(plugins ...PRPlugin) ActionsCallback { continue } errCount++ - env.Errorf("%v", err) + log.errorf("%v", err) } fmt.Printf("%d plugins ran\n", len(plugins))