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:
parent
79f595d8d1
commit
74f5cf8f29
2
.github/workflows/test.yml
vendored
2
.github/workflows/test.yml
vendored
@ -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::'
|
||||
|
@ -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,
|
||||
|
@ -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:
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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()
|
||||
|
@ -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
85
internal/output/style.go
Normal 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
44
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),
|
||||
|
25
task_test.go
25
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)
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
||||
|
@ -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
16
testdata/output_group/Taskfile.yml
vendored
Normal 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!'
|
Loading…
x
Reference in New Issue
Block a user