From 74f5cf8f2947c4cd5b5efa40fad81b6adecd9f58 Mon Sep 17 00:00:00 2001 From: Jay Anslow Date: Fri, 14 Jan 2022 00:11:47 +0000 Subject: [PATCH] Add support for begin/end messages with grouped output Fixes #647 This allows CI systems that support grouping (such as with [GitHub Actions's `::group::` command](https://docs.github.com/en/actions/learn-github-actions/workflow-commands-for-github-actions#grouping-log-lines) and [Azure Devops](https://docs.microsoft.com/en-us/azure/devops/pipelines/scripts/logging-commands?view=azure-devops&tabs=bash#formatting-commands)) to collapse all of the logs for a single task, to improve readability of logs ## Example The following Taskfile ``` # Taskfile.yml version: 3 output: group: begin: "::group::{{ .TASK }}" end: "::endgroup::" tasks: default: cmds: - "echo 'Hello, World!'" ``` Results in the following output ```bash $ task task: [default] echo 'Hello, World!' ::group::default Hello, World! ::endgroup:: ``` See [this GitHub Actions job](https://github.com/janslow/task/runs/4811059609?check_suite_focus=true) for a full example image image --- .github/workflows/test.yml | 2 +- cmd/task/task.go | 18 ++++++- docs/usage.md | 27 ++++++++++ internal/output/group.go | 24 +++++++-- internal/output/interleaved.go | 2 +- internal/output/output.go | 12 ++++- internal/output/output_test.go | 41 ++++++++++++-- internal/output/prefixed.go | 2 +- internal/output/style.go | 85 ++++++++++++++++++++++++++++++ task.go | 44 +++++++++------- task_test.go | 25 +++++++++ taskfile/merge.go | 2 +- taskfile/taskfile.go | 6 ++- testdata/output_group/Taskfile.yml | 16 ++++++ 14 files changed, 270 insertions(+), 36 deletions(-) create mode 100644 internal/output/style.go create mode 100644 testdata/output_group/Taskfile.yml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 08684fad..002f57c2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -27,4 +27,4 @@ jobs: run: go build -o ./bin/task -v ./cmd/task - name: Test - run: ./bin/task test + run: ./bin/task test --output=group --output-group-begin='::group::{{.TASK}}' --output-group-end='::endgroup::' diff --git a/cmd/task/task.go b/cmd/task/task.go index 81eae84b..4daf2e91 100644 --- a/cmd/task/task.go +++ b/cmd/task/task.go @@ -11,6 +11,7 @@ import ( "strings" "syscall" + outputpkg "github.com/go-task/task/v3/internal/output" "github.com/spf13/pflag" "mvdan.cc/sh/v3/syntax" @@ -72,7 +73,7 @@ func main() { concurrency int dir string entrypoint string - output string + output outputpkg.Style color bool ) @@ -91,7 +92,9 @@ func main() { pflag.BoolVar(&summary, "summary", false, "show summary about a task") pflag.StringVarP(&dir, "dir", "d", "", "sets directory of execution") pflag.StringVarP(&entrypoint, "taskfile", "t", "", `choose which Taskfile to run. Defaults to "Taskfile.yml"`) - pflag.StringVarP(&output, "output", "o", "", "sets output style: [interleaved|group|prefixed]") + pflag.StringVarP(&output.Name, "output", "o", "", "sets output style: [interleaved|group|prefixed]") + pflag.StringVar(&output.Group.Begin, "output-group-begin", "", "message template to print before a task's grouped output") + pflag.StringVar(&output.Group.End, "output-group-end", "", "message template to print after a task's grouped output") pflag.BoolVarP(&color, "color", "c", true, "colored output. Enabled by default. Set flag to false or use NO_COLOR=1 to disable") pflag.IntVarP(&concurrency, "concurrency", "C", 0, "limit number tasks to run concurrently") pflag.Parse() @@ -126,6 +129,17 @@ func main() { entrypoint = filepath.Base(entrypoint) } + if output.Name != "group" { + if output.Group.Begin != "" { + log.Fatal("task: You can't set --output-group-begin without --output=group") + return + } + if output.Group.End != "" { + log.Fatal("task: You can't set --output-group-end without --output=group") + return + } + } + e := task.Executor{ Force: force, Watch: watch, diff --git a/docs/usage.md b/docs/usage.md index 780b1f27..1db22fb4 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -959,6 +959,33 @@ tasks: finishes, so you won't have live feedback for commands that take a long time to run. +When using the `group` output, you can optionally provide a templated message +to print at the start of the group. This can be useful for instructing CI +systems to group all of the output for a given task, such as with [GitHub +Actions' `::group::` command](https://docs.github.com/en/actions/learn-github-actions/workflow-commands-for-github-actions#grouping-log-lines). + +```yaml +version: '3' + +output: + group: + begin: '::begin::{{.TASK}}' + end: '::endgroup::' + +tasks: + default: + cmds: + - echo 'Hello, World!' + silent: true +``` + +```bash +$ task default +::begin::default +Hello, World! +::endgroup:: +``` + The `prefix` output will prefix every line printed by a command with `[task-name] ` as the prefix, but you can customize the prefix for a command with the `prefix:` attribute: diff --git a/internal/output/group.go b/internal/output/group.go index 06d6c900..bddcdaeb 100644 --- a/internal/output/group.go +++ b/internal/output/group.go @@ -5,15 +5,25 @@ import ( "io" ) -type Group struct{} +type Group struct{ + Begin, End string +} -func (Group) WrapWriter(w io.Writer, _ string) io.Writer { - return &groupWriter{writer: w} +func (g Group) WrapWriter(w io.Writer, _ string, tmpl Templater) io.Writer { + gw := &groupWriter{writer: w} + if g.Begin != "" { + gw.begin = tmpl.Replace(g.Begin) + "\n" + } + if g.End != "" { + gw.end = tmpl.Replace(g.End) + "\n" + } + return gw } type groupWriter struct { writer io.Writer buff bytes.Buffer + begin, end string } func (gw *groupWriter) Write(p []byte) (int, error) { @@ -21,6 +31,14 @@ func (gw *groupWriter) Write(p []byte) (int, error) { } func (gw *groupWriter) Close() error { + if gw.buff.Len() == 0 { + // don't print begin/end messages if there's no buffered entries + return nil + } + if _, err := io.WriteString(gw.writer, gw.begin); err != nil { + return err + } + gw.buff.WriteString(gw.end) _, err := io.Copy(gw.writer, &gw.buff) return err } diff --git a/internal/output/interleaved.go b/internal/output/interleaved.go index 5d1b50a0..b2e3b05b 100644 --- a/internal/output/interleaved.go +++ b/internal/output/interleaved.go @@ -6,6 +6,6 @@ import ( type Interleaved struct{} -func (Interleaved) WrapWriter(w io.Writer, _ string) io.Writer { +func (Interleaved) WrapWriter(w io.Writer, _ string, _ Templater) io.Writer { return w } diff --git a/internal/output/output.go b/internal/output/output.go index 633e3c11..ce651ad2 100644 --- a/internal/output/output.go +++ b/internal/output/output.go @@ -4,6 +4,14 @@ import ( "io" ) -type Output interface { - WrapWriter(w io.Writer, prefix string) io.Writer + +// Templater executes a template engine. +// It is provided by the templater.Templater package. +type Templater interface { + // Replace replaces the provided template string with a rendered string. + Replace(tmpl string) string +} + +type Output interface { + WrapWriter(w io.Writer, prefix string, tmpl Templater) io.Writer } diff --git a/internal/output/output_test.go b/internal/output/output_test.go index cbdc48e4..fbab330e 100644 --- a/internal/output/output_test.go +++ b/internal/output/output_test.go @@ -6,6 +6,8 @@ import ( "io" "testing" + "github.com/go-task/task/v3/internal/templater" + "github.com/go-task/task/v3/taskfile" "github.com/stretchr/testify/assert" "github.com/go-task/task/v3/internal/output" @@ -14,7 +16,7 @@ import ( func TestInterleaved(t *testing.T) { var b bytes.Buffer var o output.Output = output.Interleaved{} - var w = o.WrapWriter(&b, "") + var w = o.WrapWriter(&b, "", nil) fmt.Fprintln(w, "foo\nbar") assert.Equal(t, "foo\nbar\n", b.String()) @@ -25,7 +27,7 @@ func TestInterleaved(t *testing.T) { func TestGroup(t *testing.T) { var b bytes.Buffer var o output.Output = output.Group{} - var w = o.WrapWriter(&b, "").(io.WriteCloser) + var w = o.WrapWriter(&b, "", nil).(io.WriteCloser) fmt.Fprintln(w, "foo\nbar") assert.Equal(t, "", b.String()) @@ -35,10 +37,43 @@ func TestGroup(t *testing.T) { assert.Equal(t, "foo\nbar\nbaz\n", b.String()) } +func TestGroupWithBeginEnd(t *testing.T) { + tmpl := templater.Templater{ + Vars: &taskfile.Vars{ + Keys: []string{"VAR1"}, + Mapping: map[string]taskfile.Var{ + "VAR1": {Static: "example-value"}, + }, + }, + } + + var o output.Output = output.Group{ + Begin: "::group::{{ .VAR1 }}", + End: "::endgroup::", + } + t.Run("simple", func(t *testing.T) { + var b bytes.Buffer + var w = o.WrapWriter(&b, "", &tmpl).(io.WriteCloser) + + fmt.Fprintln(w, "foo\nbar") + assert.Equal(t, "", b.String()) + fmt.Fprintln(w, "baz") + assert.Equal(t, "", b.String()) + assert.NoError(t, w.Close()) + assert.Equal(t, "::group::example-value\nfoo\nbar\nbaz\n::endgroup::\n", b.String()) + }) + t.Run("no output", func(t *testing.T) { + var b bytes.Buffer + var w = o.WrapWriter(&b, "", &tmpl).(io.WriteCloser) + assert.NoError(t, w.Close()) + assert.Equal(t, "", b.String()) + }) +} + func TestPrefixed(t *testing.T) { var b bytes.Buffer var o output.Output = output.Prefixed{} - var w = o.WrapWriter(&b, "prefix").(io.WriteCloser) + var w = o.WrapWriter(&b, "prefix", nil).(io.WriteCloser) t.Run("simple use cases", func(t *testing.T) { b.Reset() diff --git a/internal/output/prefixed.go b/internal/output/prefixed.go index 91224202..f7fb7c71 100644 --- a/internal/output/prefixed.go +++ b/internal/output/prefixed.go @@ -9,7 +9,7 @@ import ( type Prefixed struct{} -func (Prefixed) WrapWriter(w io.Writer, prefix string) io.Writer { +func (Prefixed) WrapWriter(w io.Writer, prefix string, _ Templater) io.Writer { return &prefixWriter{writer: w, prefix: prefix} } diff --git a/internal/output/style.go b/internal/output/style.go new file mode 100644 index 00000000..b670a688 --- /dev/null +++ b/internal/output/style.go @@ -0,0 +1,85 @@ +package output + +import ( + "fmt" +) + +// Style of the Task output +type Style struct { + // Name of the Style. + Name string `yaml:"-"` + // Group specific style + Group GroupStyle +} + +// Build the Output for the requested Style. +func (s *Style) Build() (Output, error) { + switch s.Name { + case "interleaved", "": + return Interleaved{}, s.ensureGroupStyleUnset() + case "group": + return Group{ + Begin: s.Group.Begin, + End: s.Group.End, + }, nil + case "prefixed": + return Prefixed{}, s.ensureGroupStyleUnset() + default: + return nil, fmt.Errorf(`task: output style %q not recognized`, s.Name) + } +} + +func (s *Style) ensureGroupStyleUnset() error { + if s.Group.IsSet() { + return fmt.Errorf("task: output style %q does not support the group begin/end parameter", s.Name) + } + return nil +} + +// IsSet returns true if and only if a custom output style is set. +func (s *Style) IsSet() bool { + return s.Name != "" +} + +// UnmarshalYAML implements yaml.Unmarshaler +// It accepts a scalar node representing the Style.Name or a mapping node representing the GroupStyle. +func (s *Style) UnmarshalYAML(unmarshal func(interface{}) error) error { + var name string + if err := unmarshal(&name); err == nil { + return s.UnmarshalText([]byte(name)) + } + var tmp struct { + Group *GroupStyle + } + if err := unmarshal(&tmp); err != nil { + return fmt.Errorf("task: output style must be a string or mapping with a \"group\" key: %w", err) + } + if tmp.Group == nil { + return fmt.Errorf("task: output style must have the \"group\" key when in mapping form") + } + *s = Style{ + Name: "group", + Group: *tmp.Group, + } + return nil +} + +// UnmarshalText implements encoding.TextUnmarshaler +// It accepts the Style.Node +func (s *Style) UnmarshalText(text []byte) error { + tmp := Style{Name: string(text)} + if _, err := tmp.Build(); err != nil { + return err + } + return nil +} + +// GroupStyle is the style options specific to the Group style. +type GroupStyle struct{ + Begin, End string +} + +// IsSet returns true if and only if a custom output style is set. +func (g *GroupStyle) IsSet() bool { + return g != nil && *g != GroupStyle{} +} diff --git a/task.go b/task.go index 17e5be10..4219ccb2 100644 --- a/task.go +++ b/task.go @@ -16,6 +16,7 @@ import ( "github.com/go-task/task/v3/internal/logger" "github.com/go-task/task/v3/internal/output" "github.com/go-task/task/v3/internal/summary" + "github.com/go-task/task/v3/internal/templater" "github.com/go-task/task/v3/taskfile" "github.com/go-task/task/v3/taskfile/read" @@ -51,7 +52,7 @@ type Executor struct { Logger *logger.Logger Compiler compiler.Compiler Output output.Output - OutputStyle string + OutputStyle output.Style taskvars *taskfile.Vars @@ -148,11 +149,11 @@ func (e *Executor) Setup() error { v = 2.6 } if v == 3.0 { - v = 3.7 + v = 3.8 } - if v > 3.7 { - return fmt.Errorf(`task: Taskfile versions greater than v3.7 not implemented in the version of Task`) + if v > 3.8 { + return fmt.Errorf(`task: Taskfile versions greater than v3.8 not implemented in the version of Task`) } // Color available only on v3 @@ -194,7 +195,7 @@ func (e *Executor) Setup() error { } } - if v < 2.1 && e.Taskfile.Output != "" { + if v < 2.1 && !e.Taskfile.Output.IsSet() { return fmt.Errorf(`task: Taskfile option "output" is only available starting on Taskfile version v2.1`) } if v < 2.2 && e.Taskfile.Includes.Len() > 0 { @@ -203,19 +204,17 @@ func (e *Executor) Setup() error { if v >= 3.0 && e.Taskfile.Expansions > 2 { return fmt.Errorf(`task: The "expansions" setting is not available anymore on v3.0`) } - - if e.OutputStyle != "" { - e.Taskfile.Output = e.OutputStyle + if v < 3.8 && e.Taskfile.Output.Group.IsSet() { + return fmt.Errorf(`task: Taskfile option "output.group" is only available starting on Taskfile version v3.8`) } - switch e.Taskfile.Output { - case "", "interleaved": - e.Output = output.Interleaved{} - case "group": - e.Output = output.Group{} - case "prefixed": - e.Output = output.Prefixed{} - default: - return fmt.Errorf(`task: output option "%s" not recognized`, e.Taskfile.Output) + + if !e.OutputStyle.IsSet() { + e.OutputStyle = e.Taskfile.Output + } + if o, err := e.OutputStyle.Build(); err != nil { + return err + } else { + e.Output = o } if e.Taskfile.Method == "" { @@ -435,8 +434,13 @@ func (e *Executor) runCommand(ctx context.Context, t *taskfile.Task, call taskfi if t.Interactive { outputWrapper = output.Interleaved{} } - stdOut := outputWrapper.WrapWriter(e.Stdout, t.Prefix) - stdErr := outputWrapper.WrapWriter(e.Stderr, t.Prefix) + vars, err := e.Compiler.FastGetVariables(t, call) + outputTemplater := &templater.Templater{Vars: vars, RemoveNoValue: true} + if err != nil { + return fmt.Errorf("task: failed to get variables: %w", err) + } + stdOut := outputWrapper.WrapWriter(e.Stdout, t.Prefix, outputTemplater) + stdErr := outputWrapper.WrapWriter(e.Stderr, t.Prefix, outputTemplater) defer func() { if _, ok := stdOut.(*os.File); !ok { @@ -451,7 +455,7 @@ func (e *Executor) runCommand(ctx context.Context, t *taskfile.Task, call taskfi } }() - err := execext.RunCommand(ctx, &execext.RunCommandOptions{ + err = execext.RunCommand(ctx, &execext.RunCommandOptions{ Command: cmd.Cmd, Dir: t.Dir, Env: getEnviron(t), diff --git a/task_test.go b/task_test.go index cb75bb00..2fa4458f 100644 --- a/task_test.go +++ b/task_test.go @@ -1171,3 +1171,28 @@ func TestIgnoreNilElements(t *testing.T) { }) } } + +func TestOutputGroup(t *testing.T) { + const dir = "testdata/output_group" + var buff bytes.Buffer + e := task.Executor{ + Dir: dir, + Stdout: &buff, + Stderr: &buff, + } + assert.NoError(t, e.Setup()) + + expectedOutputOrder := strings.TrimSpace(` +task: [hello] echo 'Hello!' +::group::hello +Hello! +::endgroup:: +task: [bye] echo 'Bye!' +::group::bye +Bye! +::endgroup:: +`) + assert.NoError(t, e.Run(context.Background(), taskfile.Call{Task: "bye"})) + t.Log(buff.String()) + assert.Equal(t, strings.TrimSpace(buff.String()), expectedOutputOrder) +} diff --git a/taskfile/merge.go b/taskfile/merge.go index 45d40571..4bab0e23 100644 --- a/taskfile/merge.go +++ b/taskfile/merge.go @@ -17,7 +17,7 @@ func Merge(t1, t2 *Taskfile, namespaces ...string) error { if t2.Expansions != 0 && t2.Expansions != 2 { t1.Expansions = t2.Expansions } - if t2.Output != "" { + if t2.Output.IsSet() { t1.Output = t2.Output } diff --git a/taskfile/taskfile.go b/taskfile/taskfile.go index 9fe45a06..d28534f1 100644 --- a/taskfile/taskfile.go +++ b/taskfile/taskfile.go @@ -3,13 +3,15 @@ package taskfile import ( "fmt" "strconv" + + "github.com/go-task/task/v3/internal/output" ) // Taskfile represents a Taskfile.yml type Taskfile struct { Version string Expansions int - Output string + Output output.Style Method string Includes *IncludedTaskfiles Vars *Vars @@ -25,7 +27,7 @@ func (tf *Taskfile) UnmarshalYAML(unmarshal func(interface{}) error) error { var taskfile struct { Version string Expansions int - Output string + Output output.Style Method string Includes *IncludedTaskfiles Vars *Vars diff --git a/testdata/output_group/Taskfile.yml b/testdata/output_group/Taskfile.yml new file mode 100644 index 00000000..1da69b5e --- /dev/null +++ b/testdata/output_group/Taskfile.yml @@ -0,0 +1,16 @@ +version: '3' + +output: + group: + begin: '::group::{{ .TASK }}' + end: '::endgroup::' + +tasks: + hello: + cmds: + - echo 'Hello!' + bye: + deps: + - hello + cmds: + - echo 'Bye!'