From 35af7499c892c45921689ba0f613fbddc071b687 Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Sun, 15 Sep 2024 15:14:21 -0700 Subject: [PATCH] refactor: cleans up code --- forge/cli/cmd/cmds/ci.go | 4 +- forge/cli/pkg/executor/local.go | 4 + forge/cli/tui/ci/app.go | 110 ++++++++++++++ forge/cli/tui/ci/ci.go | 244 ++++++++++++++++++++++++++++++++ forge/cli/tui/common.go | 41 ++++++ forge/cli/tui/debug.go | 11 -- forge/cli/tui/pipeline/scan.go | 69 --------- forge/cli/tui/pipeline/tui.go | 195 ------------------------- forge/cli/tui/tui.go | 119 ---------------- 9 files changed, 401 insertions(+), 396 deletions(-) create mode 100644 forge/cli/tui/ci/app.go create mode 100644 forge/cli/tui/ci/ci.go create mode 100644 forge/cli/tui/common.go delete mode 100644 forge/cli/tui/debug.go delete mode 100644 forge/cli/tui/pipeline/scan.go delete mode 100644 forge/cli/tui/pipeline/tui.go delete mode 100644 forge/cli/tui/tui.go diff --git a/forge/cli/cmd/cmds/ci.go b/forge/cli/cmd/cmds/ci.go index e9d9666..da396e0 100644 --- a/forge/cli/cmd/cmds/ci.go +++ b/forge/cli/cmd/cmds/ci.go @@ -3,7 +3,7 @@ package cmds import ( "log/slog" - "github.com/input-output-hk/catalyst-forge/forge/cli/tui/pipeline" + "github.com/input-output-hk/catalyst-forge/forge/cli/tui/ci" ) type CICmd struct { @@ -19,5 +19,5 @@ func (c *CICmd) Run(logger *slog.Logger, global GlobalArgs) error { } opts := generateOpts(&flags, &global) filters := []string{"^check.*$", "^build.*$", "^test.*$"} - return pipeline.Start(c.Path, filters, global.Local, opts...) + return ci.Run(c.Path, filters, global.Local, opts...) } diff --git a/forge/cli/pkg/executor/local.go b/forge/cli/pkg/executor/local.go index 843aaf0..7943d0a 100644 --- a/forge/cli/pkg/executor/local.go +++ b/forge/cli/pkg/executor/local.go @@ -77,6 +77,10 @@ func WithRedirect() LocalExecutorOption { // NewLocalExecutor creates a new LocalExecutor with the given options. func NewLocalExecutor(logger *slog.Logger, options ...LocalExecutorOption) *LocalExecutor { + if logger == nil { + logger = slog.New(slog.NewTextHandler(io.Discard, nil)) + } + e := &LocalExecutor{ logger: logger, } diff --git a/forge/cli/tui/ci/app.go b/forge/cli/tui/ci/app.go new file mode 100644 index 0000000..fb9d4ca --- /dev/null +++ b/forge/cli/tui/ci/app.go @@ -0,0 +1,110 @@ +package ci + +import ( + "log/slog" + + "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" + "github.com/input-output-hk/catalyst-forge/forge/cli/pkg/earthly" + "github.com/input-output-hk/catalyst-forge/forge/cli/pkg/project" + "github.com/input-output-hk/catalyst-forge/forge/cli/tui" +) + +// App represents the TUI application. +type App struct { + ci CI + logger *slog.Logger + window tui.Window +} + +func (a App) Init() tea.Cmd { + a.logger.Info("Starting CI simulation") + return a.ci.Run() +} + +func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + a.window.Resize(msg) + return a, nil + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c", "esc": + return a, tea.Quit + } + case spinner.TickMsg: + return a, tea.Batch(a.ci.UpdateSpinners(msg)...) + case CIRunFinishedMsg: + a.logger.Info("Received CI run finished message", "project", msg.Run.Project.Path, "target", msg.Run.Target) + if a.ci.Finished() { + a.logger.Info("All CI runs finished for current group") + out := a.ci.View() + cmd, err := a.ci.Next() + if err != nil { + a.logger.Info("No more runs") + return a, tea.Sequence( + tea.Println(out), + tea.Quit, + ) + } + + a.logger.Info("Starting next group") + return a, tea.Sequence( + tea.Println(out), + cmd, + ) + } + } + + return a, nil +} + +func (a App) View() string { + return a.ci.View() +} + +// Run starts the TUI application. +func Run(scanPath string, + filters []string, + local bool, + opts ...earthly.EarthlyExecutorOption, +) error { + logger, f, err := tui.NewLogger() + if err != nil { + return err + } + defer f.Close() + + loader := project.NewDefaultProjectLoader( + false, + local, + project.GetDefaultRuntimes(logger), + logger, + ) + + ci := CI{ + filters: filters, + loader: &loader, + logger: logger, + options: opts, + scanPath: scanPath, + } + + logger.Info("Loading project") + if err := ci.Load(); err != nil { + return err + } + + app := App{ + ci: ci, + logger: logger, + } + + logger.Info("Starting program") + p := tea.NewProgram(app) + if _, err := p.Run(); err != nil { + return err + } + + return nil +} diff --git a/forge/cli/tui/ci/ci.go b/forge/cli/tui/ci/ci.go new file mode 100644 index 0000000..478ca61 --- /dev/null +++ b/forge/cli/tui/ci/ci.go @@ -0,0 +1,244 @@ +package ci + +import ( + "errors" + "fmt" + "log/slog" + "regexp" + "strings" + + "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/input-output-hk/catalyst-forge/forge/cli/pkg/earthly" + "github.com/input-output-hk/catalyst-forge/forge/cli/pkg/executor" + "github.com/input-output-hk/catalyst-forge/forge/cli/pkg/project" + "github.com/input-output-hk/catalyst-forge/forge/cli/pkg/scan" + "github.com/input-output-hk/catalyst-forge/forge/cli/pkg/secrets" + "github.com/input-output-hk/catalyst-forge/tools/pkg/walker" +) + +const ( + RunStatusIdle RunStatus = "idle" + RunStatusRunning RunStatus = "running" + RunStatusFailed RunStatus = "failed" + RunStatusSuccess RunStatus = "success" +) + +var ( + checkMark = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).SetString("✓") + ErrNoMoreRuns = errors.New("no more runs") +) + +// RunStatus represents the status of a CI run. +type RunStatus string + +type CIRunFinishedMsg struct { + Run *CIRun +} + +// CI represents a CI simulation. +type CI struct { + filters []string + groups []*CIRunGroup + index int + loader project.ProjectLoader + logger *slog.Logger + options []earthly.EarthlyExecutorOption + scanPath string +} + +// Finished returns true if the active run group has finished. +func (c *CI) Finished() bool { + return c.groups[c.index].Finished() +} + +// Load loads the CI runs to be executed. +func (c *CI) Load() error { + w := walker.NewDefaultFSWalker(nil) + var groups []*CIRunGroup + + projects, err := scan.ScanProjects(c.scanPath, c.loader, &w, c.logger) + if err != nil { + return err + } + + for _, filter := range c.filters { + var runs []*CIRun + + filterExpr, err := regexp.Compile(filter) + if err != nil { + return err + } + + for _, project := range projects { + if project.Earthfile != nil { + targets := project.Earthfile.FilterTargets(func(target string) bool { + return filterExpr.MatchString(target) + }) + + for _, target := range targets { + runs = append(runs, &CIRun{ + Project: &project, + Status: RunStatusIdle, + Target: target, + logger: c.logger, + options: c.options, + spinner: spinner.New(), + }) + } + } + } + + groups = append(groups, &CIRunGroup{ + Runs: runs, + }) + } + + c.groups = groups + return nil +} + +// Next returns the next command to be executed. If there are no more runs, it +// returns an error. +func (c *CI) Next() (tea.Cmd, error) { + c.index++ + if c.index >= len(c.groups) { + return nil, ErrNoMoreRuns + } + + return c.groups[c.index].Run(), nil +} + +// Run starts the CI simulation. +func (c *CI) Run() tea.Cmd { + var cmds []tea.Cmd + cmds = append(cmds, c.groups[0].Run()) + for _, group := range c.groups { + for _, run := range group.Runs { + cmds = append(cmds, run.spinner.Tick) + } + } + return tea.Batch(cmds...) +} + +// UpdateSpinner updates the spinners of the CI simulation. +func (c *CI) UpdateSpinners(msg tea.Msg) []tea.Cmd { + var cmds []tea.Cmd + for _, group := range c.groups { + for _, run := range group.Runs { + cmds = append(cmds, run.UpdateSpinner(msg)) + } + } + + return cmds +} + +// View returns the current view of the CI simulation. +func (c *CI) View() string { + if len(c.groups) > 0 && c.index < len(c.groups) { + return c.groups[c.index].View() + } else { + return "" + } +} + +// CIRunGroup represents a group of CI runs. +type CIRunGroup struct { + Runs []*CIRun +} + +// Run starts the CI run group. +func (c *CIRunGroup) Run() tea.Cmd { + var cmds []tea.Cmd + for _, run := range c.Runs { + cmds = append(cmds, run.Run) + } + + return tea.Batch(cmds...) +} + +// View returns the view of the CI run group. +func (c *CIRunGroup) View() string { + var view string + for _, run := range c.Runs { + view += run.View() + "\n" + } + + return strings.TrimSuffix(view, "\n") +} + +// Finished returns true if all runs in the group have finished. +func (c *CIRunGroup) Finished() bool { + for _, run := range c.Runs { + if run.Status == RunStatusIdle || run.Status == RunStatusRunning { + return false + } + } + + return true +} + +// CIRun represents a CI run. +type CIRun struct { + Project *project.Project + Status RunStatus + Target string + logger *slog.Logger + options []earthly.EarthlyExecutorOption + spinner spinner.Model +} + +// Run starts the CI run. +func (c *CIRun) Run() tea.Msg { + c.logger.Info("Running target", "project", c.Project.Path, "target", c.Target) + c.Status = RunStatusRunning + _, err := c.Project.RunTarget( + c.Target, + executor.NewLocalExecutor(c.logger), + secrets.NewDefaultSecretStore(), + c.options..., + ) + + if err != nil { + c.logger.Error("Failed to run target", "project", c.Project.Path, "target", c.Target, "error", err) + c.Status = RunStatusFailed + } else { + c.logger.Info("Target ran successfully", "project", c.Project.Path, "target", c.Target) + c.Status = RunStatusSuccess + } + + return CIRunFinishedMsg{ + Run: c, + } +} + +// UpdateSpinner updates the spinner of the CI run. +func (c *CIRun) UpdateSpinner(msg tea.Msg) tea.Cmd { + switch msg := msg.(type) { + case spinner.TickMsg: + if msg.ID == c.spinner.ID() { + var cmd tea.Cmd + c.spinner, cmd = c.spinner.Update(msg) + return cmd + } + } + + return nil +} + +// View returns the view of the CI run. +func (c *CIRun) View() string { + switch c.Status { + case RunStatusIdle: + return fmt.Sprintf("%s %s", lipgloss.NewStyle().Foreground(lipgloss.Color("241")).SetString("•"), c.Project.Path+"+"+c.Target) + case RunStatusRunning: + return fmt.Sprintf("%s %s", c.spinner.View(), c.Project.Path+"+"+c.Target) + case RunStatusFailed: + return fmt.Sprintf("%s %s", lipgloss.NewStyle().Foreground(lipgloss.Color("196")).SetString("✗"), c.Project.Path+"+"+c.Target) + case RunStatusSuccess: + return fmt.Sprintf("%s %s", checkMark, c.Project.Path+"+"+c.Target) + default: + return "" + } +} diff --git a/forge/cli/tui/common.go b/forge/cli/tui/common.go new file mode 100644 index 0000000..d8156ff --- /dev/null +++ b/forge/cli/tui/common.go @@ -0,0 +1,41 @@ +package tui + +import ( + "log/slog" + "os" + "time" + + tea "github.com/charmbracelet/bubbletea" +) + +// Window represents the dimensions of a terminal window. +type Window struct { + Height int + Width int +} + +// Resize updates the dimensions of the window. +func (w *Window) Resize(msg tea.WindowSizeMsg) { + w.Height, w.Width = msg.Height, msg.Width +} + +// NewLogger creates a new logger and returns it along with the file it writes +// to. The file should be closed when the logger is no longer needed. +func NewLogger() (*slog.Logger, *os.File, error) { + f, err := os.Create("debug.log") + if err != nil { + return nil, nil, err + } + + options := slog.HandlerOptions{ + ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr { + if a.Key == slog.TimeKey { + // Format time as HH:MM:SS instead of the default RFC3339 format + a.Value = slog.StringValue(time.Now().Format("15:04:05")) + } + return a + }, + } + + return slog.New(slog.NewTextHandler(f, &options)), f, nil +} diff --git a/forge/cli/tui/debug.go b/forge/cli/tui/debug.go deleted file mode 100644 index 8746770..0000000 --- a/forge/cli/tui/debug.go +++ /dev/null @@ -1,11 +0,0 @@ -package tui - -import "os" - -func MakeDebugFile() (*os.File, error) { - f, err := os.Create("debug.log") - if err != nil { - return nil, err - } - return f, nil -} diff --git a/forge/cli/tui/pipeline/scan.go b/forge/cli/tui/pipeline/scan.go deleted file mode 100644 index 88858dd..0000000 --- a/forge/cli/tui/pipeline/scan.go +++ /dev/null @@ -1,69 +0,0 @@ -package pipeline - -import ( - "regexp" - - "github.com/input-output-hk/catalyst-forge/forge/cli/internal/testutils" - "github.com/input-output-hk/catalyst-forge/forge/cli/pkg/project" - "github.com/input-output-hk/catalyst-forge/forge/cli/pkg/scan" - "github.com/input-output-hk/catalyst-forge/tools/pkg/walker" -) - -type runStatus string - -const ( - runStatusIdle runStatus = "idle" - runStatusRunning runStatus = "running" - runStatusFailed runStatus = "failed" - runStatusSuccess runStatus = "success" -) - -// ProjectTarget represents a project and a target. -type ProjectTarget struct { - project project.Project - status runStatus - target string -} - -func scanProjects( - startPath string, - loader project.ProjectLoader, - filters []string, -) ([][]*ProjectTarget, error) { - w := walker.NewDefaultFSWalker(testutils.NewNoopLogger()) - var result [][]*ProjectTarget - - projects, err := scan.ScanProjects(startPath, loader, &w, testutils.NewNoopLogger()) - if err != nil { - return nil, err - } - - for _, filter := range filters { - var pairs []*ProjectTarget - - filterExpr, err := regexp.Compile(filter) - if err != nil { - return nil, err - } - - for _, project := range projects { - if project.Earthfile != nil { - targets := project.Earthfile.FilterTargets(func(target string) bool { - return filterExpr.MatchString(target) - }) - - for _, target := range targets { - pairs = append(pairs, &ProjectTarget{ - project: project, - status: runStatusIdle, - target: target, - }) - } - } - } - - result = append(result, pairs) - } - - return result, nil -} diff --git a/forge/cli/tui/pipeline/tui.go b/forge/cli/tui/pipeline/tui.go deleted file mode 100644 index 3880f6e..0000000 --- a/forge/cli/tui/pipeline/tui.go +++ /dev/null @@ -1,195 +0,0 @@ -package pipeline - -import ( - "fmt" - "os" - "strings" - - "github.com/charmbracelet/bubbles/spinner" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/input-output-hk/catalyst-forge/forge/cli/internal/testutils" - "github.com/input-output-hk/catalyst-forge/forge/cli/pkg/earthly" - "github.com/input-output-hk/catalyst-forge/forge/cli/pkg/executor" - "github.com/input-output-hk/catalyst-forge/forge/cli/pkg/project" - "github.com/input-output-hk/catalyst-forge/forge/cli/pkg/secrets" - "github.com/input-output-hk/catalyst-forge/forge/cli/tui" -) - -type groupMsg struct { - targets []*ProjectTarget -} - -type finishedMsg struct { - index int -} - -type model struct { - log *os.File - height int - width int - spinner spinner.Model - opts []earthly.EarthlyExecutorOption - groupIndex int - runs [][]*ProjectTarget -} - -var ( - checkMark = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).SetString("✓") -) - -func (m model) Init() tea.Cmd { - fmt.Fprint(m.log, "Starting pipeline\n") - return tea.Batch(runGroup(m), m.spinner.Tick) -} - -func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.WindowSizeMsg: - m.height, m.width = msg.Height, msg.Width - case tea.KeyMsg: - switch msg.String() { - case "ctrl+c", "esc": - return m, tea.Quit - } - case spinner.TickMsg: - var cmd tea.Cmd - m.spinner, cmd = m.spinner.Update(msg) - return m, cmd - case groupMsg: - fmt.Fprintf(m.log, "Running group %d\n", m.groupIndex) - var cmds []tea.Cmd - for i := range msg.targets { - m.runs[m.groupIndex][i].status = runStatusRunning - cmds = append(cmds, runTarget(m, i)) - } - - return m, tea.Batch(cmds...) - case finishedMsg: - fmt.Fprintf(m.log, "Got finished message for target %d\n", msg.index) - fmt.Fprintf(m.log, "Pairs: %+v\n", m.runs[m.groupIndex]) - for _, pair := range m.runs[m.groupIndex] { - if pair.status == runStatusRunning { - return m, nil - } - } - - m.groupIndex++ - if m.groupIndex < len(m.runs) { - fmt.Fprintf(m.log, "Group %d finished\n", m.groupIndex-1) - - return m, tea.Sequence( - tea.Println(makeStatusView(m.runs[m.groupIndex-1], "")), - runGroup(m), - ) - } else { - return m, tea.Sequence( - tea.Println(makeStatusView(m.runs[m.groupIndex-1], "")), - tea.Quit, - ) - } - } - return m, nil -} - -func (m model) View() string { - if m.groupIndex < len(m.runs) { - return makeStatusView(m.runs[m.groupIndex], m.spinner.View()) - } else { - return "" - } -} - -func makeStatusView(runs []*ProjectTarget, spin string) string { - var view string - - for _, pair := range runs { - switch pair.status { - case runStatusIdle: - view += fmt.Sprintf("%s %s\n", lipgloss.NewStyle().Foreground(lipgloss.Color("241")).SetString("•"), pair.project.Path+"+"+pair.target) - case runStatusRunning: - view += fmt.Sprintf("%s %s\n", spin, pair.project.Path+"+"+pair.target) - case runStatusFailed: - view += fmt.Sprintf("%s %s\n", lipgloss.NewStyle().Foreground(lipgloss.Color("196")).SetString("✗"), pair.project.Path+"+"+pair.target) - case runStatusSuccess: - view += fmt.Sprintf("%s %s\n", checkMark, pair.project.Path+"+"+pair.target) - } - } - - return strings.TrimSuffix(view, "\n") -} - -func runGroup(m model) tea.Cmd { - return func() tea.Msg { - return groupMsg{targets: m.runs[m.groupIndex]} - } -} - -func runTarget(m model, index int) tea.Cmd { - return func() tea.Msg { - pair := m.runs[m.groupIndex][index] - localExec := executor.NewLocalExecutor( - testutils.NewNoopLogger(), - ) - - fmt.Fprintf(m.log, "Running target %s+%s\n", pair.project.Path, pair.target) - _, err := pair.project.RunTarget( - pair.target, - localExec, - secrets.NewDefaultSecretStore(), - m.opts..., - ) - - if err != nil { - fmt.Fprintf(m.log, "Failed to run target %s+%s: %s\n", pair.project.Path, pair.target, err) - pair.status = runStatusFailed - } else { - fmt.Fprintf(m.log, "Successfully ran target %s+%s\n", pair.project.Path, pair.target) - pair.status = runStatusSuccess - } - - return finishedMsg{ - index: index, - } - } -} - -func Start( - startPath string, - filters []string, - local bool, - opts ...earthly.EarthlyExecutorOption, -) error { - log, err := tui.MakeDebugFile() - if err != nil { - return err - } - defer log.Close() - - loader := project.NewDefaultProjectLoader( - false, - local, - project.GetDefaultRuntimes(testutils.NewNoopLogger()), - testutils.NewNoopLogger(), - ) - - fmt.Fprintf(log, "Scanning projects in %s\n", startPath) - runs, err := scanProjects(startPath, &loader, filters) - if err != nil { - return err - } - - model := model{ - log: log, - opts: opts, - runs: runs, - spinner: spinner.New(), - } - - p := tea.NewProgram(model) - if _, err := p.Run(); err != nil { - return err - } - - return nil -} diff --git a/forge/cli/tui/tui.go b/forge/cli/tui/tui.go deleted file mode 100644 index 1dafddc..0000000 --- a/forge/cli/tui/tui.go +++ /dev/null @@ -1,119 +0,0 @@ -package tui - -import ( - "fmt" - "os" - - "github.com/charmbracelet/bubbles/spinner" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/input-output-hk/catalyst-forge/forge/cli/internal/testutils" - "github.com/input-output-hk/catalyst-forge/forge/cli/pkg/earthly" - "github.com/input-output-hk/catalyst-forge/forge/cli/pkg/executor" - "github.com/input-output-hk/catalyst-forge/forge/cli/pkg/project" - "github.com/input-output-hk/catalyst-forge/forge/cli/pkg/secrets" -) - -type run bool -type done bool - -type model struct { - log *os.File - height int - width int - spinner spinner.Model - opts []earthly.EarthlyExecutorOption - project project.Project - running bool - target string -} - -var ( - checkMark = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).SetString("✓") -) - -func (m model) Init() tea.Cmd { - return tea.Batch(func() tea.Msg { return run(true) }, m.spinner.Tick) -} - -func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.WindowSizeMsg: - m.height, m.width = msg.Height, msg.Width - case tea.KeyMsg: - switch msg.String() { - case "ctrl+c", "esc": - return m, tea.Quit - } - case spinner.TickMsg: - var cmd tea.Cmd - m.spinner, cmd = m.spinner.Update(msg) - return m, cmd - case done: - fmt.Fprint(m.log, "Done\n") - return m, tea.Sequence( - tea.Printf("%s %s", checkMark, m.project.Path+"+"+m.target), - tea.Quit, - ) - case run: - fmt.Fprint(m.log, "Running target\n") - m.running = true - return m, runTarget(m) - } - return m, nil -} - -func (m model) View() string { - var view string - if m.running { - spin := m.spinner.View() + " " - path := m.project.Path + "+" + m.target - view = spin + path - } - return view -} - -func runTarget(m model) tea.Cmd { - return func() tea.Msg { - localExec := executor.NewLocalExecutor( - testutils.NewNoopLogger(), - ) - - fmt.Fprintf(m.log, "Running target %s\n", m.target) - m.project.RunTarget( - m.target, - localExec, - secrets.NewDefaultSecretStore(), - m.opts..., - ) - - return done(true) - } -} - -func Start( - project project.Project, - target string, - opts ...earthly.EarthlyExecutorOption, -) error { - f, err := tea.LogToFile("debug.log", "debug") - if err != nil { - return err - } - defer f.Close() - - model := model{ - log: f, - opts: opts, - project: project, - spinner: spinner.New(), - target: target, - } - - p := tea.NewProgram(model) - if _, err := p.Run(); err != nil { - return err - } - - return nil -}