Skip to content

Commit

Permalink
v0.0.26: add error reporting capabilities
Browse files Browse the repository at this point in the history
  • Loading branch information
geemus committed Apr 29, 2024
1 parent 39329f6 commit 4bb2104
Show file tree
Hide file tree
Showing 19 changed files with 422 additions and 47 deletions.
11 changes: 1 addition & 10 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,19 +23,10 @@ jobs:
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: 'Caches: go'
uses: actions/cache@v4
with:
path: |
~/.cache/go-build
~/go/pkg/mod
key: go-${{ runner.os }}-${{ github.job }}-${{ hashFiles(inputs.key-files) }}
restore-keys: |
go-${{ runner.os }}-${{ github.job }}-
go-${{ runner.os }}-
- name: Set up Go
uses: actions/setup-go@v4
with:
cache: true
go-version-file: go.mod
- shell: bash
run: |
Expand Down
109 changes: 108 additions & 1 deletion cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,18 @@ package cli
import (
"context"
"fmt"
"go/build"
"net/url"
"os"
"regexp"
"runtime"
"strings"

"github.com/anchordotdev/cli/models"
"github.com/anchordotdev/cli/ui"
"github.com/cli/browser"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)

var Version = struct {
Expand Down Expand Up @@ -76,7 +84,8 @@ type Config struct {
} `cmd:"lcl"`

Test struct {
SkipRunE bool `desc:"skip RunE for testing purposes"`
Browserless bool `desc:"run as though browserless"`
SkipRunE bool `desc:"skip RunE for testing purposes"`
}

Trust struct {
Expand Down Expand Up @@ -130,3 +139,101 @@ func ConfigFromCmd(cmd *cobra.Command) *Config {
func ContextWithConfig(ctx context.Context, cfg *Config) context.Context {
return context.WithValue(ctx, ContextKey("Config"), cfg)
}

var (
stackHexRegexp = regexp.MustCompile(`0x[0-9a-f]{2,}\??`)
stackNilRegexp = regexp.MustCompile(`0x0`)

stackPathReplacer *strings.Replacer
)

func init() {
goPaths := strings.Split(os.Getenv("GOPATH"), string(os.PathListSeparator))
if len(goPaths) == 0 {
goPaths = append(goPaths, build.Default.GOPATH)
}
if goPaths[0] == "" {
goPaths[0] = build.Default.GOPATH
}

joinedGoPaths := strings.Join(goPaths, ",<gopath>,") + ",<gopath>"
replacements := strings.Split(joinedGoPaths, ",")
replacements = append(replacements, runtime.GOROOT(), "<goroot>")

if pwd, _ := os.Getwd(); pwd != "" {
replacements = append(replacements, pwd, "<pwd>")
}

stackPathReplacer = strings.NewReplacer(replacements...)
}

func normalizeStack(stack string) string {
// TODO: more nuanced replace for other known values like true/false, maybe empty string?
stack = stackPathReplacer.Replace(stack)
stack = stackHexRegexp.ReplaceAllString(stack, "<hex>")
stack = stackNilRegexp.ReplaceAllString(stack, "<nil>")
stack = strings.TrimRight(stack, "\n")

lines := strings.Split(stack, "\n")
for i, line := range lines {
if strings.Contains(line, "<goroot>") {
// for lines like: `<goroot>/src/runtime/debug/stack.go:24 +<hex>`
lines[i] = fmt.Sprintf("%s:<line> +<hex>", strings.Split(line, ":")[0])
}
if strings.Contains(line, "in goroutine") {
lines[i] = fmt.Sprintf("%s in gouroutine <int>", strings.Split(line, " in goroutine ")[0])
}
}

return strings.Join(lines, "\n")
}

func ReportError(ctx context.Context, drv *ui.Driver, cmd *cobra.Command, args []string, msg any, stack string) {
cfg := ConfigFromContext(ctx)

var flags []string
cmd.Flags().Visit(func(flag *pflag.Flag) {
flags = append(flags, flag.Name)
})

q := url.Values{}
q.Add("title", fmt.Sprintf("Error: %s", msg))

var body strings.Builder

fmt.Fprintf(&body, "**Are there any additional details you would like to share?**\n")
fmt.Fprintf(&body, "\n")
fmt.Fprintf(&body, "---\n")
fmt.Fprintf(&body, "\n")
fmt.Fprintf(&body, "**Command:** `%s`\n", cmd.CalledAs())
fmt.Fprintf(&body, "**Version:** `%s`\n", VersionString())
fmt.Fprintf(&body, "**Arguments:** `[%s]`\n", strings.Join(args, ", "))
fmt.Fprintf(&body, "**Flags:** `[%s]`\n", strings.Join(flags, ", "))
if stack != "" {
fmt.Fprintf(&body, "**Stack:**\n```\n%s\n```\n", normalizeStack(stack))
}
fmt.Fprintf(&body, "**Stdout:**\n```\n%s\n```\n", strings.TrimRight(string(drv.FinalOut()), "\n"))
q.Add("body", body.String())

reportErrorConfirmCh := make(chan struct{})
drv.Activate(ctx, &models.ReportError{
ConfirmCh: reportErrorConfirmCh,
Cmd: cmd,
Args: args,
Msg: msg,
})

if !cfg.NonInteractive {
<-reportErrorConfirmCh
}

newIssueURL := fmt.Sprintf("https://github.com/anchordotdev/cli/issues/new?%s", q.Encode())
// FIXME: ? remove config val, switch to mocking this to always err in tests
if cfg.Test.Browserless {
drv.Activate(ctx, &models.Browserless{Url: newIssueURL})
} else {
if err := browser.OpenURL(newIssueURL); err != nil {
drv.Activate(ctx, &models.Browserless{Url: newIssueURL})
}
}
}
103 changes: 103 additions & 0 deletions cli_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package cli_test

import (
"context"
"errors"
"fmt"
"runtime"
"testing"
"time"

"github.com/anchordotdev/cli"
"github.com/anchordotdev/cli/ui"
"github.com/anchordotdev/cli/ui/uitest"
"github.com/charmbracelet/x/exp/teatest"
"github.com/spf13/cobra"
"github.com/stretchr/testify/require"
)

var CmdError = cli.NewCmd[ErrorCommand](nil, "error", func(cmd *cobra.Command) {})

type ErrorCommand struct{}

func (c ErrorCommand) UI() cli.UI {
return cli.UI{
RunTUI: c.run,
}
}

var testErr = errors.New("test error")

func (c *ErrorCommand) run(ctx context.Context, drv *ui.Driver) error {
return testErr
}

func TestError(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()

cfg := cli.Config{}
cfg.NonInteractive = true
cfg.Test.Browserless = true
ctx = cli.ContextWithConfig(ctx, &cfg)

t.Run(fmt.Sprintf("golden-%s_%s", runtime.GOOS, runtime.GOARCH), func(t *testing.T) {
var returnedError error

drv, tm := uitest.TestTUI(ctx, t)

cmd := ErrorCommand{}

defer func() {
require.Error(t, returnedError)
require.EqualError(t, returnedError, testErr.Error())

tm.WaitFinished(t, teatest.WithFinalTimeout(time.Second*3))
teatest.RequireEqualOutput(t, drv.FinalOut())
}()
defer cli.Cleanup(&returnedError, nil, ctx, drv, CmdError, []string{})
returnedError = cmd.UI().RunTUI(ctx, drv)
})
}

var CmdPanic = cli.NewCmd[PanicCommand](nil, "error", func(cmd *cobra.Command) {})

type PanicCommand struct{}

func (c PanicCommand) UI() cli.UI {
return cli.UI{
RunTUI: c.run,
}
}

func (c *PanicCommand) run(ctx context.Context, drv *ui.Driver) error {
panic("test panic")
}

func TestPanic(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()

cfg := cli.Config{}
cfg.NonInteractive = true
cfg.Test.Browserless = true
ctx = cli.ContextWithConfig(ctx, &cfg)

t.Run(fmt.Sprintf("golden-%s_%s", runtime.GOOS, runtime.GOARCH), func(t *testing.T) {
var returnedError error

drv, tm := uitest.TestTUI(ctx, t)

cmd := PanicCommand{}

defer func() {
require.Error(t, returnedError)
require.EqualError(t, returnedError, "test panic")

tm.WaitFinished(t, teatest.WithFinalTimeout(time.Second*3))
teatest.RequireEqualOutput(t, drv.FinalOut())
}()
defer cli.Cleanup(&returnedError, nil, ctx, drv, CmdPanic, []string{})
_ = cmd.UI().RunTUI(ctx, drv)
})
}
53 changes: 39 additions & 14 deletions cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ package cli

import (
"context"
"fmt"
"runtime/debug"
"strings"

"github.com/MakeNowJust/heredoc"
"github.com/anchordotdev/cli/ui"
Expand Down Expand Up @@ -182,8 +185,6 @@ var cmdDefByCommands = map[*cobra.Command]*CmdDef{}

var constructorByCommands = map[*cobra.Command]func() *cobra.Command{}

var version string

type UIer interface {
UI() UI
}
Expand Down Expand Up @@ -216,11 +217,12 @@ func NewCmd[T UIer](parent *cobra.Command, name string, fn func(*cobra.Command))
}

cmd := &cobra.Command{
Use: def.Use,
Args: def.Args,
Short: def.Short,
Long: def.Long,
SilenceUsage: true,
Use: def.Use,
Args: def.Args,
Short: def.Short,
Long: def.Long,
SilenceErrors: true,
SilenceUsage: true,
}

ctx := ContextWithConfig(context.Background(), cfg)
Expand All @@ -245,7 +247,7 @@ func NewCmd[T UIer](parent *cobra.Command, name string, fn func(*cobra.Command))
panic(err)
}

cmd.RunE = func(cmd *cobra.Command, args []string) error {
cmd.RunE = func(cmd *cobra.Command, args []string) (returnedError error) {
cfg := ConfigFromCmd(cmd)
if cfg.Test.SkipRunE {
return nil
Expand All @@ -263,6 +265,7 @@ func NewCmd[T UIer](parent *cobra.Command, name string, fn func(*cobra.Command))
defer cancel(nil)

drv, prg := ui.NewDriverTUI(ctx)

errc := make(chan error)

go func() {
Expand All @@ -274,16 +277,12 @@ func NewCmd[T UIer](parent *cobra.Command, name string, fn func(*cobra.Command))
errc <- err
}()

defer Cleanup(&returnedError, errc, ctx, drv, cmd, args)
if err := t.UI().RunTUI(ctx, drv); err != nil && err != context.Canceled {
prg.Quit()

<-errc // TODO: report this somewhere?
return err
}

prg.Quit()

return <-errc // TODO: special handling for a UI error
return nil
}

return cmd
Expand All @@ -298,6 +297,32 @@ func NewCmd[T UIer](parent *cobra.Command, name string, fn func(*cobra.Command))
return cmd
}

func Cleanup(returnedError *error, errc chan error, ctx context.Context, drv *ui.Driver, cmd *cobra.Command, args []string) {
var stack string

panicMsg := recover()
if panicMsg != nil {
lines := strings.Split(string(debug.Stack()), "\n")
// omit lines: 0 go routine, 2-3 stack call, 4-5 cleanup call
stack = strings.Join(lines[5:], "\n")

*returnedError = fmt.Errorf("%s", panicMsg)
}

if *returnedError != nil {
ReportError(ctx, drv, cmd, args, *returnedError, stack)
}

// release/restore
drv.Program.Quit()
drv.Program.Wait()

// FIXME: handle UI errors?
if errc != nil {
*returnedError = <-errc
}
}

func NewTestCmd(cmd *cobra.Command) *cobra.Command {
return constructorByCommands[cmd]()
}
12 changes: 6 additions & 6 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,13 @@ require (
github.com/muesli/termenv v0.15.2
github.com/oapi-codegen/runtime v1.1.1
github.com/spf13/cobra v1.8.0
github.com/stretchr/testify v1.8.4
github.com/spf13/pflag v1.0.5
github.com/stretchr/testify v1.9.0
github.com/zalando/go-keyring v0.2.4
golang.org/x/crypto v0.21.0
golang.org/x/crypto v0.22.0
golang.org/x/exp v0.0.0-20240318143956-a85f2c67cd81
golang.org/x/sync v0.6.0
golang.org/x/sys v0.18.0
golang.org/x/sync v0.7.0
golang.org/x/sys v0.19.0
howett.net/plist v1.0.1
)

Expand Down Expand Up @@ -63,11 +64,10 @@ require (
github.com/rivo/uniseg v0.4.7 // indirect
github.com/rogpeppe/go-internal v1.10.0 // indirect
github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f // indirect
github.com/spf13/pflag v1.0.5 // indirect
golang.org/x/mod v0.16.0 // indirect
golang.org/x/net v0.23.0 // indirect
golang.org/x/oauth2 v0.16.0 // indirect
golang.org/x/term v0.18.0 // indirect
golang.org/x/term v0.19.0 // indirect
golang.org/x/text v0.14.0 // indirect
golang.org/x/tools v0.19.0 // indirect
google.golang.org/appengine v1.6.8 // indirect
Expand Down
Loading

0 comments on commit 4bb2104

Please sign in to comment.