1
0
mirror of https://github.com/go-task/task.git synced 2025-02-09 13:47:06 +02:00

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

<img width="771" alt="image" src="https://user-images.githubusercontent.com/1253367/149429832-6cb0c1b5-0758-442e-9375-c4daa65771bc.png">
<img width="394" alt="image" src="https://user-images.githubusercontent.com/1253367/149429851-1d5d2ab5-9095-4795-9b57-f91750720d40.png">
This commit is contained in:
Jay Anslow 2022-01-14 00:11:47 +00:00
parent 79f595d8d1
commit 74f5cf8f29
No known key found for this signature in database
GPG Key ID: 0EBD95E58C9DEBFA
14 changed files with 270 additions and 36 deletions

View File

@ -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::'

View File

@ -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,

View File

@ -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:

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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()

View File

@ -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}
}

85
internal/output/style.go Normal file
View File

@ -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{}
}

44
task.go
View File

@ -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),

View File

@ -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)
}

View File

@ -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
}

View File

@ -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

16
testdata/output_group/Taskfile.yml vendored Normal file
View File

@ -0,0 +1,16 @@
version: '3'
output:
group:
begin: '::group::{{ .TASK }}'
end: '::endgroup::'
tasks:
hello:
cmds:
- echo 'Hello!'
bye:
deps:
- hello
cmds:
- echo 'Bye!'