From fb5dddccfccc4d586e91f1a0ff0accef834536c0 Mon Sep 17 00:00:00 2001 From: Danny Hermes Date: Wed, 21 Apr 2021 11:37:27 -0500 Subject: [PATCH] Add `Action.getenv` and use variadic options in `New()`. (#19) * Adding `Action` options. * Using options in `New()`. * Adding `Action.getenv` and `OptGetEnv` helper. * Adding godoc for `Opt*()` helpers. * Adding `getenv` default in `New()`. * Replacing `os.Getenv` usage with `Action.getenv`. * Ditching `Action.setEnv` in favor of using `OptGetenv`. * Ditching `Action.addPath` in favor of using `OptGetenv`. * Ditching `Action.getInput` in favor of using `OptGetenv`. * Ditching `Action.issueFileCommand` in favor of using `OptGetenv`. * Ditching `NewWithWriter` in tests. This will make it easier to deprecate `NewWithWriter` later. * Adding test for `NewWithWriter`. * Fix year in new `options_test.go`. * Modifying `Option` so it also returns an `Action`. * Adding a `nil`-guard for `opt` in `New()`. * Adding deprecation notice to godoc for `NewWithWriter()`. * Renaming `Opt*` to `With*` for options. The diff was completely generated via: ``` git grep -l OptWriter | xargs sed -i '' s/OptWriter/WithWriter/g git grep -l OptFields | xargs sed -i '' s/OptFields/WithFields/g git grep -l OptGetenv | xargs sed -i '' s/OptGetenv/WithGetenv/g ``` so reviewing the whole diff is not necessary (if that makes code review easier). * Exporting `getenvFunc`. Done via: ``` git grep -l getenvFunc | xargs sed -i '' s/getenvFunc/GetenvFunc/g ``` --- actions.go | 48 ++++++++++++++------------------ actions_test.go | 74 ++++++++++++++++++++++++++++++------------------- options.go | 51 ++++++++++++++++++++++++++++++++++ options_test.go | 71 +++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 188 insertions(+), 56 deletions(-) create mode 100644 options.go create mode 100644 options_test.go diff --git a/actions.go b/actions.go index 8a3903b..80a0b10 100644 --- a/actions.go +++ b/actions.go @@ -53,15 +53,24 @@ const ( // New creates a new wrapper with helpers for outputting information in GitHub // actions format. -func New() *Action { - return &Action{w: os.Stdout} +func New(opts ...Option) *Action { + a := &Action{w: os.Stdout, getenv: os.Getenv} + for _, opt := range opts { + if opt == nil { + continue + } + a = opt(a) + } + return a } // NewWithWriter creates a wrapper using the given writer. This is useful for // tests. The given writer cannot add any prefixes to the string, since GitHub // requires these special strings to match a very particular format. +// +// Deprecated: Use New() with WithWriter instead. func NewWithWriter(w io.Writer) *Action { - return &Action{w: w} + return New(WithWriter(w)) } // Action is an internal wrapper around GitHub Actions' output and magic @@ -69,6 +78,7 @@ func NewWithWriter(w io.Writer) *Action { type Action struct { w io.Writer fields CommandProperties + getenv GetenvFunc } // IssueCommand issues a new GitHub actions Command. @@ -88,15 +98,11 @@ func (c *Action) IssueCommand(cmd *Command) { // with the 'Command' argument as it's scope is unclear in the current // TypeScript implementation. func (c *Action) IssueFileCommand(cmd *Command) error { - return c.issueFileCommand(cmd, os.Getenv) -} - -func (c *Action) issueFileCommand(cmd *Command, f getenvFunc) error { e := strings.ReplaceAll(cmd.Name, "-", "_") e = strings.ToUpper(e) e = "GITHUB_" + e - err := ioutil.WriteFile(f(e), []byte(cmd.Message+"\n"), os.ModeAppend) + err := ioutil.WriteFile(c.getenv(e), []byte(cmd.Message+"\n"), os.ModeAppend) if err != nil { return fmt.Errorf(errFileCmdFmt, err) } @@ -143,14 +149,10 @@ func (c *Action) RemoveMatcher(o string) { // https://docs.github.com/en/free-pro-team@latest/actions/reference/workflow-commands-for-github-actions#adding-a-system-path // https://github.blog/changelog/2020-10-01-github-actions-deprecating-set-env-and-add-path-commands/ func (c *Action) AddPath(p string) { - c.addPath(p, os.Getenv) -} - -func (c *Action) addPath(p string, f getenvFunc) { - err := c.issueFileCommand(&Command{ + err := c.IssueFileCommand(&Command{ Name: pathCmd, Message: p, - }, f) + }) if err != nil { // use regular command as fallback // ::add-path::

@@ -175,14 +177,10 @@ func (c *Action) SaveState(k, v string) { // GetInput gets the input by the given name. func (c *Action) GetInput(i string) string { - return c.getInput(i, os.Getenv) -} - -func (c *Action) getInput(i string, f getenvFunc) string { e := strings.ReplaceAll(i, " ", "_") e = strings.ToUpper(e) e = "INPUT_" + e - return strings.TrimSpace(f(e)) + return strings.TrimSpace(c.getenv(e)) } // Group starts a new collapsable region up to the next ungroup invocation. @@ -210,14 +208,10 @@ func (c *Action) EndGroup() { // https://docs.github.com/en/free-pro-team@latest/actions/reference/workflow-commands-for-github-actions#setting-an-environment-variable // https://github.blog/changelog/2020-10-01-github-actions-deprecating-set-env-and-add-path-commands/ func (c *Action) SetEnv(k, v string) { - c.setEnv(k, v, os.Getenv) -} - -func (c *Action) setEnv(k, v string, f getenvFunc) { - err := c.issueFileCommand(&Command{ + err := c.IssueFileCommand(&Command{ Name: envCmd, Message: fmt.Sprintf(envCmdMsgFmt, k, envCmdDelimiter, v, envCmdDelimiter), - }, f) + }) if err != nil { // use regular command as fallback // ::set-env name=:: @@ -316,6 +310,6 @@ func (c *Action) WithFieldsMap(m map[string]string) *Action { } } -// getenvFunc is an abstraction to make tests feasible for commands that +// GetenvFunc is an abstraction to make tests feasible for commands that // interact with environment variables. -type getenvFunc func(k string) string +type GetenvFunc func(k string) string diff --git a/actions_test.go b/actions_test.go index 9341e35..c7c13ab 100644 --- a/actions_test.go +++ b/actions_test.go @@ -21,11 +21,27 @@ import ( "testing" ) -func TestAction_IssueCommand(t *testing.T) { +func TestNewWithWriter(t *testing.T) { t.Parallel() var b bytes.Buffer a := NewWithWriter(&b) + + a.IssueCommand(&Command{ + Name: "foo", + Message: "bar", + }) + + if got, want := b.String(), "::foo::bar\n"; got != want { + t.Errorf("expected %q to be %q", got, want) + } +} + +func TestAction_IssueCommand(t *testing.T) { + t.Parallel() + + var b bytes.Buffer + a := New(WithWriter(&b)) a.IssueCommand(&Command{ Name: "foo", Message: "bar", @@ -48,12 +64,12 @@ func TestAction_IssueFileCommand(t *testing.T) { fakeGetenvFunc := newFakeGetenvFunc(t, "GITHUB_FOO", file.Name()) var b bytes.Buffer - a := NewWithWriter(&b) + a := New(WithWriter(&b), WithGetenv(fakeGetenvFunc)) - err = a.issueFileCommand(&Command{ + err = a.IssueFileCommand(&Command{ Name: "foo", Message: "bar", - }, fakeGetenvFunc) + }) if err != nil { t.Errorf("expected nil error, got: %s", err) @@ -79,7 +95,7 @@ func TestAction_AddMask(t *testing.T) { t.Parallel() var b bytes.Buffer - a := NewWithWriter(&b) + a := New(WithWriter(&b)) a.AddMask("foobar") if got, want := b.String(), "::add-mask::foobar\n"; got != want { @@ -91,7 +107,7 @@ func TestAction_AddMatcher(t *testing.T) { t.Parallel() var b bytes.Buffer - a := NewWithWriter(&b) + a := New(WithWriter(&b)) a.AddMatcher("foobar.json") if got, want := b.String(), "::add-matcher::foobar.json\n"; got != want { @@ -103,7 +119,7 @@ func TestAction_RemoveMatcher(t *testing.T) { t.Parallel() var b bytes.Buffer - a := NewWithWriter(&b) + a := New(WithWriter(&b)) a.RemoveMatcher("foobar") if got, want := b.String(), "::remove-matcher owner=foobar::\n"; got != want { @@ -119,9 +135,9 @@ func TestAction_AddPath(t *testing.T) { // expect a regular command to be issued when env file is not set. fakeGetenvFunc := newFakeGetenvFunc(t, envGitHubPath, "") var b bytes.Buffer - a := NewWithWriter(&b) + a := New(WithWriter(&b), WithGetenv(fakeGetenvFunc)) - a.addPath("/custom/bin", fakeGetenvFunc) + a.AddPath("/custom/bin") if got, want := b.String(), "::add-path::/custom/bin\n"; got != want { t.Errorf("expected %q to be %q", got, want) } @@ -136,8 +152,9 @@ func TestAction_AddPath(t *testing.T) { defer os.Remove(file.Name()) fakeGetenvFunc = newFakeGetenvFunc(t, envGitHubPath, file.Name()) + WithGetenv(fakeGetenvFunc)(a) - a.addPath("/custom/bin", fakeGetenvFunc) + a.AddPath("/custom/bin") if got, want := b.String(), ""; got != want { t.Errorf("expected %q to be %q", got, want) @@ -163,7 +180,7 @@ func TestAction_SaveState(t *testing.T) { t.Parallel() var b bytes.Buffer - a := NewWithWriter(&b) + a := New(WithWriter(&b)) a.SaveState("key", "value") if got, want := b.String(), "::save-state name=key::value\n"; got != want { @@ -177,8 +194,8 @@ func TestAction_GetInput(t *testing.T) { fakeGetenvFunc := newFakeGetenvFunc(t, "INPUT_FOO", "bar") var b bytes.Buffer - a := NewWithWriter(&b) - if got, want := a.getInput("foo", fakeGetenvFunc), "bar"; got != want { + a := New(WithWriter(&b), WithGetenv(fakeGetenvFunc)) + if got, want := a.GetInput("foo"), "bar"; got != want { t.Errorf("expected %q to be %q", got, want) } } @@ -187,7 +204,7 @@ func TestAction_Group(t *testing.T) { t.Parallel() var b bytes.Buffer - a := NewWithWriter(&b) + a := New(WithWriter(&b)) a.Group("mygroup") if got, want := b.String(), "::group::mygroup\n"; got != want { @@ -199,7 +216,7 @@ func TestAction_EndGroup(t *testing.T) { t.Parallel() var b bytes.Buffer - a := NewWithWriter(&b) + a := New(WithWriter(&b)) a.EndGroup() if got, want := b.String(), "::endgroup::\n"; got != want { @@ -223,8 +240,8 @@ func TestAction_SetEnv(t *testing.T) { for _, check := range checks { fakeGetenvFunc := newFakeGetenvFunc(t, envGitHubEnv, "") var b bytes.Buffer - a := NewWithWriter(&b) - a.setEnv(check.key, check.value, fakeGetenvFunc) + a := New(WithWriter(&b), WithGetenv(fakeGetenvFunc)) + a.SetEnv(check.key, check.value) if got, want := b.String(), check.want; got != want { t.Errorf("SetEnv(%q, %q): expected %q; got %q", check.key, check.value, want, got) } @@ -232,7 +249,6 @@ func TestAction_SetEnv(t *testing.T) { // expectations for env file env commands var b bytes.Buffer - a := NewWithWriter(&b) file, err := ioutil.TempFile(".", ".set_env_test_") if err != nil { t.Fatalf("unable to create a temp env file: %s", err) @@ -240,8 +256,8 @@ func TestAction_SetEnv(t *testing.T) { defer os.Remove(file.Name()) fakeGetenvFunc := newFakeGetenvFunc(t, envGitHubEnv, file.Name()) - - a.setEnv("key", "value", fakeGetenvFunc) + a := New(WithWriter(&b), WithGetenv(fakeGetenvFunc)) + a.SetEnv("key", "value") // expect an empty stdout buffer if got, want := b.String(), ""; got != want { @@ -264,7 +280,7 @@ func TestAction_SetOutput(t *testing.T) { t.Parallel() var b bytes.Buffer - a := NewWithWriter(&b) + a := New(WithWriter(&b)) a.SetOutput("key", "value") if got, want := b.String(), "::set-output name=key::value\n"; got != want { @@ -276,7 +292,7 @@ func TestAction_Debugf(t *testing.T) { t.Parallel() var b bytes.Buffer - a := NewWithWriter(&b) + a := New(WithWriter(&b)) a.Debugf("fail: %s", "thing") if got, want := b.String(), "::debug::fail: thing\n"; got != want { @@ -288,7 +304,7 @@ func TestAction_Errorf(t *testing.T) { t.Parallel() var b bytes.Buffer - a := NewWithWriter(&b) + a := New(WithWriter(&b)) a.Errorf("fail: %s", "thing") if got, want := b.String(), "::error::fail: thing\n"; got != want { @@ -300,7 +316,7 @@ func TestAction_Warningf(t *testing.T) { t.Parallel() var b bytes.Buffer - a := NewWithWriter(&b) + a := New(WithWriter(&b)) a.Warningf("fail: %s", "thing") if got, want := b.String(), "::warning::fail: thing\n"; got != want { @@ -312,7 +328,7 @@ func TestAction_Infof(t *testing.T) { t.Parallel() var b bytes.Buffer - a := NewWithWriter(&b) + a := New(WithWriter(&b)) a.Infof("info: %s\n", "thing") if got, want := b.String(), "info: thing\n"; got != want { @@ -324,7 +340,7 @@ func TestAction_WithFieldsSlice(t *testing.T) { t.Parallel() var b bytes.Buffer - a := NewWithWriter(&b) + a := New(WithWriter(&b)) a = a.WithFieldsSlice([]string{"line=100", "file=app.js"}) a.Debugf("fail: %s", "thing") @@ -337,7 +353,7 @@ func TestAction_WithFieldsMap(t *testing.T) { t.Parallel() var b bytes.Buffer - a := NewWithWriter(&b) + a := New(WithWriter(&b)) a = a.WithFieldsMap(map[string]string{"line": "100", "file": "app.js"}) a.Debugf("fail: %s", "thing") @@ -346,10 +362,10 @@ func TestAction_WithFieldsMap(t *testing.T) { } } -// newFakeGetenvFunc returns a new getenvFunc that is expected to be called with +// newFakeGetenvFunc returns a new GetenvFunc that is expected to be called with // the provided key. It returns the provided value if the call matches the // provided key. It reports an error on test t otherwise. -func newFakeGetenvFunc(t *testing.T, wantKey, v string) getenvFunc { +func newFakeGetenvFunc(t *testing.T, wantKey, v string) GetenvFunc { return func(gotKey string) string { if gotKey != wantKey { t.Errorf("expected call GetenvFunc(%q) to be GetenvFunc(%q)", gotKey, wantKey) diff --git a/options.go b/options.go new file mode 100644 index 0000000..eb40773 --- /dev/null +++ b/options.go @@ -0,0 +1,51 @@ +// Copyright 2021 The Authors +// +// 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 githubactions provides an SDK for authoring GitHub Actions in Go. It +// has no external dependencies and provides a Go-like interface for interacting +// with GitHub Actions' build system. +package githubactions + +import ( + "io" +) + +// Option is a modifier for an Action +type Option func(*Action) *Action + +// WithWriter sets the writer function on an Action. By default, this will +// be `os.Stdout` from the standard library. +func WithWriter(w io.Writer) Option { + return func(a *Action) *Action { + a.w = w + return a + } +} + +// WithFields sets the extra command field on an Action. +func WithFields(fields CommandProperties) Option { + return func(a *Action) *Action { + a.fields = fields + return a + } +} + +// WithGetenv sets the `Getenv` function on an Action. By default, this will +// be `os.Getenv` from the standard library. +func WithGetenv(getenv GetenvFunc) Option { + return func(a *Action) *Action { + a.getenv = getenv + return a + } +} diff --git a/options_test.go b/options_test.go new file mode 100644 index 0000000..512f9bc --- /dev/null +++ b/options_test.go @@ -0,0 +1,71 @@ +// Copyright 2021 The Authors +// +// 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 githubactions + +import ( + "bytes" + "testing" +) + +func TestWithWriter(t *testing.T) { + t.Parallel() + + a := &Action{} + var b bytes.Buffer + opt := WithWriter(&b) + + opt(a) + a.IssueCommand(&Command{ + Name: "foo", + Message: "bar", + }) + + if got, want := b.String(), "::foo::bar\n"; got != want { + t.Errorf("expected %q to be %q", got, want) + } +} + +func TestWithFields(t *testing.T) { + t.Parallel() + + var b bytes.Buffer + a := &Action{w: &b} + opt := WithFields(map[string]string{"baz": "quux"}) + + opt(a) + a.IssueCommand(&Command{ + Name: "foo", + Message: "bar", + Properties: a.fields, + }) + + if got, want := b.String(), "::foo baz=quux::bar\n"; got != want { + t.Errorf("expected %q to be %q", got, want) + } +} + +func TestWithGetenv(t *testing.T) { + t.Parallel() + + a := &Action{} + opt := WithGetenv(func(k string) string { + return "sentinel" + }) + + opt(a) + if got, want := a.getenv("any"), "sentinel"; got != want { + t.Errorf("expected %q to be %q", got, want) + } +}