Skip to content

Commit

Permalink
Improved acceptance action
Browse files Browse the repository at this point in the history
- made `*boilerplate.Boilerplate` public
- changed slack notification API
- restructured acceptance running code into a type
  • Loading branch information
nfx committed Mar 28, 2024
1 parent e24b305 commit a8f74e1
Show file tree
Hide file tree
Showing 7 changed files with 134 additions and 85 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/nightly-go-libs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ jobs:
go-version: 1.21

- name: Acceptance
uses: databrickslabs/sandbox/acceptance@acceptance/v0.2.0
uses: databrickslabs/sandbox/acceptance@acceptance/v0.2.1
with:
directory: go-libs
vault_uri: ${{ secrets.VAULT_URI }}
Expand Down
43 changes: 24 additions & 19 deletions acceptance/boilerplate/actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import (
"github.com/sethvargo/go-githubactions"
)

func New(ctx context.Context, opts ...githubactions.Option) (*boilerplate, error) {
func New(ctx context.Context, opts ...githubactions.Option) (*Boilerplate, error) {
opts = append(opts, githubactions.WithGetenv(func(key string) string {
return env.Get(ctx, key)
}))
Expand All @@ -25,7 +25,7 @@ func New(ctx context.Context, opts ...githubactions.Option) (*boilerplate, error
return nil, err
}
logger.DefaultLogger = &actionsLogger{a}
return &boilerplate{
return &Boilerplate{
Action: a,
context: context,
GitHub: github.NewClient(&github.GitHubConfig{
Expand All @@ -35,14 +35,14 @@ func New(ctx context.Context, opts ...githubactions.Option) (*boilerplate, error
}, nil
}

type boilerplate struct {
type Boilerplate struct {
Action *githubactions.Action
context *githubactions.GitHubContext
GitHub *github.GitHubClient
uploader *artifactUploader
}

func (a *boilerplate) PrepareArtifacts() (string, error) {
func (a *Boilerplate) PrepareArtifacts() (string, error) {
tempDir, err := os.MkdirTemp(os.TempDir(), "artifacts-*")
if err != nil {
return "", fmt.Errorf("tmp: %w", err)
Expand All @@ -61,7 +61,7 @@ func (a *boilerplate) PrepareArtifacts() (string, error) {
return tempDir, nil
}

func (a *boilerplate) Upload(ctx context.Context, folder string) error {
func (a *Boilerplate) Upload(ctx context.Context, folder string) error {
charset := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
suffix := make([]byte, 12)
for i := range suffix {
Expand All @@ -75,7 +75,7 @@ func (a *boilerplate) Upload(ctx context.Context, folder string) error {
return nil
}

func (a *boilerplate) RunURL(ctx context.Context) (string, error) {
func (a *Boilerplate) RunURL(ctx context.Context) (string, error) {
org, repo := a.context.Repo()
workflowJobs := a.GitHub.ListWorkflowJobs(ctx, org, repo, a.context.RunID)
for workflowJobs.HasNext(ctx) {
Expand All @@ -92,27 +92,32 @@ func (a *boilerplate) RunURL(ctx context.Context) (string, error) {
return "", fmt.Errorf("id not found for current run: %s", a.context.Job)
}

func (a *boilerplate) CreateIssueIfNotOpen(ctx context.Context, newIssue github.NewIssue) error {
func (a *Boilerplate) CreateOrCommentOnIssue(ctx context.Context, newIssue github.NewIssue) error {
org, repo := a.context.Repo()
it := a.GitHub.ListRepositoryIssues(ctx, org, repo, &github.ListIssues{
State: "open",
})
created := map[string]bool{}
created := map[string]int{}
for it.HasNext(ctx) {
issue, err := it.Next(ctx)
if err != nil {
return fmt.Errorf("issue: %w", err)
}
created[issue.Title] = true
}
if created[newIssue.Title] {
return nil
created[issue.Title] = issue.Number
}
// with the tagged comment, which has the workflow ref, we can link to the run
body, err := a.taggedComment(ctx, newIssue.Body)
if err != nil {
return fmt.Errorf("tagged comment: %w", err)
}
// with the tagged comment, which has the workflow ref, we can link to the run
number, ok := created[newIssue.Title]
if ok {
_, err = a.GitHub.CreateIssueComment(ctx, org, repo, number, body)
if err != nil {
return fmt.Errorf("new comment: %w", err)
}
return nil
}
issue, err := a.GitHub.CreateIssue(ctx, org, repo, github.NewIssue{
Title: newIssue.Title,
Assignees: newIssue.Assignees,
Expand All @@ -122,17 +127,17 @@ func (a *boilerplate) CreateIssueIfNotOpen(ctx context.Context, newIssue github.
if err != nil {
return fmt.Errorf("new issue: %w", err)
}
logger.Infof(ctx, "Created issue: https://github.com/%s/%s/issues/%d", issue.Number)
logger.Infof(ctx, "Created new issue: https://github.com/%s/%s/issues/%d", org, repo, issue.Number)
return nil
}

func (a *boilerplate) tag() string {
func (a *Boilerplate) tag() string {
// The ref path to the workflow. For example,
// octocat/hello-world/.github/workflows/my-workflow.yml@refs/heads/my_branch.
return fmt.Sprintf("\n<!-- workflow:%s -->", a.Action.Getenv("GITHUB_WORKFLOW_REF"))
}

func (a *boilerplate) taggedComment(ctx context.Context, body string) (string, error) {
func (a *Boilerplate) taggedComment(ctx context.Context, body string) (string, error) {
runUrl, err := a.RunURL(ctx)
if err != nil {
return "", fmt.Errorf("run url: %w", err)
Expand All @@ -141,11 +146,11 @@ func (a *boilerplate) taggedComment(ctx context.Context, body string) (string, e
body, a.WorkflowRunName(), runUrl, a.tag()), nil
}

func (a *boilerplate) WorkflowRunName() string {
func (a *Boilerplate) WorkflowRunName() string {
return fmt.Sprintf("%s #%d", a.context.Workflow, a.context.RunNumber)
}

func (a *boilerplate) currentPullRequest(ctx context.Context) (*github.PullRequest, error) {
func (a *Boilerplate) currentPullRequest(ctx context.Context) (*github.PullRequest, error) {
if a.context.Event == nil {
return nil, fmt.Errorf("missing actions event")
}
Expand All @@ -163,7 +168,7 @@ func (a *boilerplate) currentPullRequest(ctx context.Context) (*github.PullReque
return event.PullRequest, nil
}

func (a *boilerplate) Comment(ctx context.Context, commentText string) error {
func (a *Boilerplate) AddOrUpdateComment(ctx context.Context, commentText string) error {
pr, err := a.currentPullRequest(ctx)
if err != nil {
return fmt.Errorf("pr: %w", err)
Expand Down
157 changes: 100 additions & 57 deletions acceptance/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ package main

import (
"context"
"errors"
"fmt"
"net/url"
"os"
"path/filepath"
"strings"
Expand All @@ -11,107 +13,142 @@ import (
"github.com/databrickslabs/sandbox/acceptance/boilerplate"
"github.com/databrickslabs/sandbox/acceptance/ecosystem"
"github.com/databrickslabs/sandbox/acceptance/notify"
"github.com/databrickslabs/sandbox/acceptance/redaction"
"github.com/databrickslabs/sandbox/acceptance/testenv"
"github.com/databrickslabs/sandbox/go-libs/env"
"github.com/databrickslabs/sandbox/go-libs/github"
"github.com/databrickslabs/sandbox/go-libs/slack"
"github.com/sethvargo/go-githubactions"
)

func main() {
err := run(context.Background())
if err != nil {
githubactions.Fatalf("failed: %s", err)
}
}

func run(ctx context.Context, opts ...githubactions.Option) error {
b, err := boilerplate.New(ctx)
b, err := boilerplate.New(ctx, opts...)
if err != nil {
return fmt.Errorf("boilerplate: %w", err)
}
timeoutRaw := b.Action.GetInput("timeout")
if timeoutRaw == "" {
timeoutRaw = "1h"
}
timeout, err := time.ParseDuration(timeoutRaw)
a := &acceptance{Boilerplate: b}
alert, err := a.trigger(ctx)
if err != nil {
return fmt.Errorf("timeout: %w", err)
return fmt.Errorf("trigger: %w", err)
}
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
vaultURI := b.Action.GetInput("vault_uri")
directory := b.Action.GetInput("directory")
project := b.Action.GetInput("project")
if project == "" {
abs, err := filepath.Abs(directory)
if err != nil {
return fmt.Errorf("absolute path: %w", err)
}
project = filepath.Base(abs)
return a.notifyIfNeeded(ctx, alert)
}

type acceptance struct {
*boilerplate.Boilerplate
}

func (a *acceptance) trigger(ctx context.Context) (*notify.Notification, error) {
vaultURI := a.Action.GetInput("vault_uri")
directory, project, err := a.getProject()
if err != nil {
return nil, fmt.Errorf("project: %w", err)
}
artifactDir, err := b.PrepareArtifacts()
artifactDir, err := a.PrepareArtifacts()
if err != nil {
return fmt.Errorf("prepare artifacts: %w", err)
return nil, fmt.Errorf("prepare artifacts: %w", err)
}
defer os.RemoveAll(artifactDir)
testEnv := testenv.NewWithGitHubOIDC(b.Action, vaultURI)
testEnv := testenv.NewWithGitHubOIDC(a.Action, vaultURI)
loaded, err := testEnv.Load(ctx)
if err != nil {
return fmt.Errorf("load: %w", err)
return nil, fmt.Errorf("load: %w", err)
}
ctx, stop, err := loaded.Start(ctx)
if err != nil {
return fmt.Errorf("start: %w", err)
return nil, fmt.Errorf("start: %w", err)
}
defer stop()
// make sure that test logs leave their artifacts somewhere we can pickup
ctx = env.Set(ctx, ecosystem.LogDirEnv, artifactDir)
redact := loaded.Redaction()
// detect and run all tests
report, err := ecosystem.RunAll(ctx, redact, directory)
report, err := a.runWithTimeout(ctx, redact, directory)
if err != nil {
return fmt.Errorf("unknown: %w", err)
return nil, fmt.Errorf("run: %w", err)
}
err = report.WriteReport(project, filepath.Join(artifactDir, "test-report.json"))
if err != nil {
return fmt.Errorf("report: %w", err)
return nil, fmt.Errorf("report: %w", err)
}
// better be redacting twice, right?
summary := redact.ReplaceAll(report.StepSummary())
b.Action.AddStepSummary(summary)
err = b.Comment(ctx, summary)
a.Action.AddStepSummary(summary)
err = a.AddOrUpdateComment(ctx, summary)
if err != nil {
return nil, fmt.Errorf("comment: %w", err)
}
err = a.Upload(ctx, artifactDir)
if err != nil {
return nil, fmt.Errorf("upload artifact: %w", err)
}
runUrl, err := a.RunURL(ctx)
if err != nil {
return nil, fmt.Errorf("run url: %w", err)
}
kvStoreURL, err := url.Parse(vaultURI)
if err != nil {
return fmt.Errorf("comment: %w", err)
return nil, fmt.Errorf("vault uri: %w", err)
}
runName := strings.TrimSuffix(kvStoreURL.Host, ".vault.azure.net")
return &notify.Notification{
Project: project,
Report: report,
Cloud: loaded.Cloud(),
RunName: runName,
RunURL: runUrl,
}, nil
}

func (a *acceptance) runWithTimeout(
ctx context.Context, redact redaction.Redaction, directory string,
) (ecosystem.TestReport, error) {
timeoutRaw := a.Action.GetInput("timeout")
if timeoutRaw == "" {
timeoutRaw = "50m"
}
err = b.Upload(ctx, artifactDir)
timeout, err := time.ParseDuration(timeoutRaw)
if err != nil {
return fmt.Errorf("upload artifact: %w", err)
return nil, fmt.Errorf("timeout: %w", err)
}
slackWebhook := b.Action.GetInput("slack_webhook")
createIssues := strings.ToLower(b.Action.GetInput("create_issues"))
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
// detect and run all tests
report, err := ecosystem.RunAll(ctx, redact, directory)
if err == nil || errors.Is(err, context.DeadlineExceeded) {
return report, nil
}
return nil, fmt.Errorf("unknown: %w", err)
}

func (a *acceptance) notifyIfNeeded(ctx context.Context, alert *notify.Notification) error {
slackWebhook := a.Action.GetInput("slack_webhook")
createIssues := strings.ToLower(a.Action.GetInput("create_issues"))
needsSlack := slackWebhook != ""
needsIssues := createIssues == "true" || createIssues == "yes"
needsNotification := needsSlack || needsIssues
if !report.Pass() && needsNotification {
runUrl, err := b.RunURL(ctx)
if err != nil {
return fmt.Errorf("run url: %w", err)
}
alert := notify.Notification{
Project: project,
Report: report,
Cloud: loaded.Cloud(),
RunName: b.WorkflowRunName(),
WebHook: slackWebhook,
RunURL: runUrl,
}
if !alert.Report.Pass() && needsNotification {
if needsSlack {
err = alert.ToSlack()
hook := slack.Webhook(slackWebhook)
err := alert.ToSlack(hook)
if err != nil {
return fmt.Errorf("slack: %w", err)
}
}
if needsIssues {
for _, v := range report {
for _, v := range alert.Report {
if !v.Failed() {
continue
}
err = b.CreateIssueIfNotOpen(ctx, github.NewIssue{
Title: fmt.Sprintf("Test failure: `%s`", v.Name),
Body: v.Summary(),
err := a.CreateOrCommentOnIssue(ctx, github.NewIssue{
Title: fmt.Sprintf("Test failure: `%s`", v.Name),
Body: v.Summary(),
Labels: []string{"bug"},
})
if err != nil {
Expand All @@ -120,12 +157,18 @@ func run(ctx context.Context, opts ...githubactions.Option) error {
}
}
}
return report.Failed()
return alert.Report.Failed()
}

func main() {
err := run(context.Background())
if err != nil {
githubactions.Fatalf("failed: %s", err)
func (a *acceptance) getProject() (string, string, error) {
directory := a.Action.GetInput("directory")
project := a.Action.GetInput("project")
if project == "" {
abs, err := filepath.Abs(directory)
if err != nil {
return "", "", fmt.Errorf("absolute path: %w", err)
}
project = filepath.Base(abs)
}
return directory, project, nil
}
4 changes: 1 addition & 3 deletions acceptance/notify/slack.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ type Notification struct {
Cloud config.Cloud
RunName string
Report ecosystem.TestReport
WebHook string
RunURL string
}

Expand All @@ -26,7 +25,7 @@ var icons = map[config.Cloud]string{
config.CloudGCP: "https://cloud.google.com/favicon.ico",
}

func (n Notification) ToSlack() error {
func (n Notification) ToSlack(hook slack.Webhook) error {
var failures, flakes []string
for _, v := range n.Report {
if v.Skip {
Expand Down Expand Up @@ -62,7 +61,6 @@ func (n Notification) ToSlack() error {
if n.RunName == "" {
n.RunName = string(n.Cloud)
}
hook := slack.Webhook(n.WebHook)
return hook.Notify(slack.Message{
Text: n.Report.String(),
UserName: n.Project,
Expand Down
Loading

0 comments on commit a8f74e1

Please sign in to comment.