diff --git a/cmd/survey/cmd.go b/cmd/survey/cmd.go index ff1ae29..184fa4a 100644 --- a/cmd/survey/cmd.go +++ b/cmd/survey/cmd.go @@ -46,7 +46,7 @@ func runStartCmd(cmd *cobra.Command, args []string) error { } defer cleanup() - tasks := initTasks(svc) + surveyTasks := initTasks(svc) if cmd.Flags().Changed("interface") { if _, ok := interfaces[interfaceType]; !ok { @@ -58,10 +58,10 @@ func runStartCmd(cmd *cobra.Command, args []string) error { } if cmd.Flags().Changed("stage") { - if stage < 1 || stage > len(tasks) { + if stage < 1 || stage > len(surveyTasks) { return fmt.Errorf("invalid stage") } - if err := runTask(cmd.Context(), tasks[stage-1], interfaces[interfaceType]); err != nil { + if err := runTask(cmd.Context(), surveyTasks[stage-1], interfaces[interfaceType]); err != nil { return err } return nil @@ -71,8 +71,6 @@ func runStartCmd(cmd *cobra.Command, args []string) error { return returnIfUserQuit(err, "failed to run intro") } - surveyTasks := initTasks(svc) - if err := taskLoop(cmd.Context(), surveyTasks, interfaces); err != nil { return returnIfUserQuit(err, "task loop failed") } diff --git a/cmd/survey/init.go b/cmd/survey/init.go index fdaacd8..998245b 100644 --- a/cmd/survey/init.go +++ b/cmd/survey/init.go @@ -3,12 +3,13 @@ package main import ( "context" - "github.com/LazyBachelor/LazyPM/cmd/survey/tasks" "github.com/LazyBachelor/LazyPM/internal/service" "github.com/LazyBachelor/LazyPM/pkg/cli/repl" "github.com/LazyBachelor/LazyPM/pkg/task" "github.com/LazyBachelor/LazyPM/pkg/tui" "github.com/LazyBachelor/LazyPM/pkg/web" + + _ "github.com/LazyBachelor/LazyPM/cmd/survey/tasks" ) func initializeServices(ctx context.Context) (*service.Services, func(), error) { @@ -21,12 +22,6 @@ func initializeServices(ctx context.Context) (*service.Services, func(), error) return service.NewServices(ctx, config) } -func initTasks(svc *service.Services) []*task.Task { - return []*task.Task{ - tasks.NewCreateIssueTask(svc).Init(), - } -} - func initInterfaces() map[string]task.Interface { return map[string]task.Interface{ "repl": repl.NewRepl(), @@ -34,3 +29,17 @@ func initInterfaces() map[string]task.Interface { "web": web.NewWeb(), } } + +func initTasks(svc *service.Services) []task.Tasker { + var taskers []task.Tasker + + for _, name := range task.List() { + t, err := task.Get(name, svc) + if err != nil { + continue + } + taskers = append(taskers, t) + } + + return taskers +} diff --git a/cmd/survey/runner.go b/cmd/survey/runner.go index d46ae10..3e213f1 100644 --- a/cmd/survey/runner.go +++ b/cmd/survey/runner.go @@ -10,56 +10,11 @@ import ( "github.com/LazyBachelor/LazyPM/pkg/task" ) -func runTask(ctx context.Context, t *task.Task, i task.Interface) error { - t.SetInterface(i) - t.SetInterfaceType(tasks.InterfaceToType(i)) - - doneChan := make(chan bool, 1) - quitChan := make(chan bool, 1) - feedbackChan := make(chan task.ValidationFeedback, 10) - - if validated, ok := i.(task.ValidatedInterface); ok { - validated.SetChannels(feedbackChan, quitChan) - } - - if err := t.Initialize(ctx); err != nil { - return fmt.Errorf("failed to initialize task: %w", err) - } - - if err := t.IntroduceTask(); err != nil { - return returnIfUserQuit(err, "failed to display task introduction screen") - } - - t.SetChannels(feedbackChan, doneChan, quitChan) - - go t.StartValidationLoop(ctx) - - interfaceDone := make(chan error, 1) - go func() { - interfaceDone <- t.StartInterface(ctx, t.Config) - }() - - select { - case <-doneChan: - close(quitChan) - <-interfaceDone - fmt.Println("Task completed successfully!") - - case err := <-interfaceDone: - close(quitChan) - if err != nil { - return returnIfUserQuit(err, "failed to start task interface") - } - fmt.Println("Task incomplete - you exited early") - } - - if err := t.StartQuestionnaire(); err != nil { - return returnIfUserQuit(err, "failed to start questionnaire") - } - return nil +func runTask(ctx context.Context, t task.Tasker, i task.Interface) error { + return task.RunTask(ctx, t, i, tasks.InterfaceToType(i)) } -func taskLoop(ctx context.Context, surveyTasks []*task.Task, interfaces map[string]task.Interface) error { +func taskLoop(ctx context.Context, surveyTasks []task.Tasker, interfaces map[string]task.Interface) error { var ifaceNames []string for name := range interfaces { ifaceNames = append(ifaceNames, name) @@ -69,11 +24,11 @@ func taskLoop(ctx context.Context, surveyTasks []*task.Task, interfaces map[stri ifaceNames[i], ifaceNames[j] = ifaceNames[j], ifaceNames[i] }) - for i, task := range surveyTasks { + for i, t := range surveyTasks { idx := i % len(ifaceNames) selected := interfaces[ifaceNames[idx]] - if err := runTask(ctx, task, selected); err != nil { + if err := runTask(ctx, t, selected); err != nil { return err } } @@ -81,7 +36,7 @@ func taskLoop(ctx context.Context, surveyTasks []*task.Task, interfaces map[stri } func returnIfUserQuit(err error, msg string) error { - if errors.Is(err, ErrUserQuit) { + if errors.Is(err, task.ErrUserQuit) { return nil } return fmt.Errorf("%s: %w", msg, err) diff --git a/cmd/survey/tasks/codingTask.go b/cmd/survey/tasks/codingTask.go new file mode 100644 index 0000000..9c1966e --- /dev/null +++ b/cmd/survey/tasks/codingTask.go @@ -0,0 +1,71 @@ +package tasks + +import ( + "context" + + "github.com/LazyBachelor/LazyPM/internal/service" + "github.com/LazyBachelor/LazyPM/pkg/task" + taskui "github.com/LazyBachelor/LazyPM/pkg/task/ui" + "github.com/charmbracelet/huh" +) + +const codingDescription = `You are tasked with writing a simple function. + +Write a function that takes two integers and returns their sum. +The function should be named "Add" and be part of the "coding" package.` + +func init() { + task.Register("coding_task", func(svc *service.Services) task.Tasker { + return NewCodingTask(svc) + }) +} + +type CodingTask struct { + svc *service.Services +} + +func NewCodingTask(svc *service.Services) *CodingTask { + return &CodingTask{svc: svc} +} + +func (t *CodingTask) Config() task.TaskConfig { + return task.TaskConfig{ + IssuePrefix: "pm", + BeadsDBPath: "./.pm/db.db", + StatisticsStoragePath: "./.pm/coding-stats.json", + WebAddress: "localhost:8080", + } +} + +func (t *CodingTask) Details() taskui.TaskDetails { + return taskui.TaskDetails{ + Title: "Coding Task", + Description: codingDescription, + TimeToComplete: "10m", + Difficulty: "Easy", + } +} + +func (t *CodingTask) Questions(interfaceType task.InterfaceType) taskui.Questions { + return taskui.Questions{ + huh.NewGroup(huh.NewConfirm().Title("Did you complete the coding task?")), + huh.NewGroup( + huh.NewSelect[int](). + Options( + huh.NewOption("Very easy", 1), + huh.NewOption("Easy", 2), + huh.NewOption("Moderate", 3), + huh.NewOption("Hard", 4), + ). + Title("How difficult was the task?"), + ), + } +} + +func (t *CodingTask) Setup(ctx context.Context) error { + return nil +} + +func (t *CodingTask) Validate(ctx context.Context) (bool, error) { + return true, nil +} diff --git a/cmd/survey/tasks/createIssue.go b/cmd/survey/tasks/createIssue.go index 5dbc3bc..ffdf6fa 100644 --- a/cmd/survey/tasks/createIssue.go +++ b/cmd/survey/tasks/createIssue.go @@ -7,7 +7,7 @@ import ( "github.com/LazyBachelor/LazyPM/internal/models" "github.com/LazyBachelor/LazyPM/internal/service" "github.com/LazyBachelor/LazyPM/pkg/task" - ui "github.com/LazyBachelor/LazyPM/pkg/task/ui" + taskui "github.com/LazyBachelor/LazyPM/pkg/task/ui" "github.com/charmbracelet/huh" ) @@ -17,23 +17,18 @@ This task will test your ability to use the issue creation workflow effectively. Assign this task to yourself and start creating the issue. Make sure to fill out all the necessary details, including the title, description, and assignee.` +func init() { + task.Register("create_issue", func(svc *service.Services) task.Tasker { + return NewCreateIssueTask(svc) + }) +} + type CreateIssueTask struct { - *task.Task svc *service.Services } func NewCreateIssueTask(svc *service.Services) *CreateIssueTask { - return &CreateIssueTask{ - svc: svc, - } -} - -func (t *CreateIssueTask) Init() *task.Task { - task := task.NewTask(t.svc, t.Details(), t.QuestionsFunc()) - task.SetConfigFunc(t.Config) - task.SetDbStateFunc(t.DbStateFunc) - task.SetValidateFunc(t.ValidateFunc) - return task + return &CreateIssueTask{svc: svc} } func (t *CreateIssueTask) Config() task.TaskConfig { @@ -45,65 +40,47 @@ func (t *CreateIssueTask) Config() task.TaskConfig { } } -func (t *CreateIssueTask) Details() ui.TaskDetails { - return ui.TaskDetails{ +func (t *CreateIssueTask) Details() taskui.TaskDetails { + return taskui.TaskDetails{ Title: "Create Issue Task", - Description: "Create a new issue in the project management system to test the issue creation workflow.", + Description: "Create a new issue in the project management system...", TimeToComplete: "15m", Difficulty: "Hard", } } -func (t *CreateIssueTask) QuestionsFunc() task.QuestionsFunc { - return func(interfaceType task.InterfaceType) ui.Questions { - questions := ui.Questions{} - - // Add an extra question for TUI - if interfaceType == task.InterfaceTUI { - questions = append(questions, - huh.NewGroup( - huh.NewConfirm().Title("Did you complete?"), - ), - ) - } - - questions = append(questions, - huh.NewGroup( - huh.NewConfirm().Title("Was this good"), - ), - huh.NewGroup( - huh.NewSelect[int]().Options( +func (t *CreateIssueTask) Questions(interfaceType task.InterfaceType) taskui.Questions { + return taskui.Questions{ + huh.NewGroup(huh.NewConfirm().Title("Was this good")), + huh.NewGroup( + huh.NewSelect[int](). + Options( huh.NewOption("Very good", 1), huh.NewOption("Very Bad", 2), - ).Title("How good was it?"), - ), - ) - - return questions + ). + Title("How good was it?"), + ), } } -func (t *CreateIssueTask) DbStateFunc(ctx context.Context) error { +func (t *CreateIssueTask) Setup(ctx context.Context) error { // Clear existing issues to ensure a clean state for the task if err := t.svc.DeleteIssues(); err != nil { return err } issue := models.Issue{ - ID: "pm-abc", - Title: "Create A New Issue", Description: description, - IssueType: models.TypeTask, Status: models.StatusOpen, + ID: "pm-abc", + Title: "Create A New Issue", + Description: description, + IssueType: models.TypeTask, + Status: models.StatusOpen, } - if err := t.svc.Beads.CreateIssue(ctx, &issue, ""); err != nil { - return err - } - - return nil + return t.svc.Beads.CreateIssue(ctx, &issue, "") } -func (t *CreateIssueTask) ValidateFunc(ctx context.Context) (ok bool, errorMsg error) { - // Fetches issues, indexed with latest first +func (t *CreateIssueTask) Validate(ctx context.Context) (bool, error) { issues, err := t.svc.Beads.SearchIssues(ctx, "", models.IssueFilter{}) if err != nil { return false, err diff --git a/pkg/task/register.go b/pkg/task/register.go new file mode 100644 index 0000000..7fda855 --- /dev/null +++ b/pkg/task/register.go @@ -0,0 +1,32 @@ +package task + +import ( + "fmt" + + "github.com/LazyBachelor/LazyPM/internal/service" +) + +var registry = make(map[string]func(*service.Services) Tasker) + +func Register(name string, constructor func(*service.Services) Tasker) { + if _, exists := registry[name]; exists { + panic(fmt.Sprintf("task %q already registered", name)) + } + registry[name] = constructor +} + +func Get(name string, svc *service.Services) (Tasker, error) { + constructor, ok := registry[name] + if !ok { + return nil, fmt.Errorf("task %q not found", name) + } + return constructor(svc), nil +} + +func List() []string { + names := make([]string, 0, len(registry)) + for name := range registry { + names = append(names, name) + } + return names +} diff --git a/pkg/task/runner.go b/pkg/task/runner.go new file mode 100644 index 0000000..6579ef4 --- /dev/null +++ b/pkg/task/runner.go @@ -0,0 +1,111 @@ +package task + +import ( + "context" + "fmt" + "time" + + taskui "github.com/LazyBachelor/LazyPM/pkg/task/ui" + tea "github.com/charmbracelet/bubbletea" +) + +var ErrUserQuit = fmt.Errorf("user quit") + +// RunTask orchestrates the complete task execution flow: +// 1. Setup the task +// 2. Show task intro screen +// 3. Run the interface +// 4. Start validation loop in background +// 5. Show questionnaire when done +func RunTask(ctx context.Context, t Tasker, i Interface, ifaceType InterfaceType) error { + doneChan := make(chan bool, 1) + quitChan := make(chan bool, 1) + feedbackChan := make(chan ValidationFeedback, 10) + + if validated, ok := i.(ValidatedInterface); ok { + validated.SetChannels(feedbackChan, quitChan) + } + + // Setup task + if err := t.Setup(ctx); err != nil { + return fmt.Errorf("failed to setup task: %w", err) + } + + // Show task intro + detailsScreen := taskui.NewTaskModel(t.Details()) + model, err := tea.NewProgram(detailsScreen, tea.WithAltScreen()).Run() + if err != nil { + return err + } + if m, ok := model.(interface{ GetUserQuit() bool }); ok && m.GetUserQuit() { + return ErrUserQuit + } + + // Start validation loop + go startValidationLoop(ctx, t, feedbackChan, doneChan, quitChan) + + // Run interface + interfaceDone := make(chan error, 1) + go func() { + interfaceDone <- i.Run(ctx, t.Config()) + }() + + select { + case <-doneChan: + close(quitChan) + <-interfaceDone + fmt.Println("Task completed successfully!") + + case err := <-interfaceDone: + close(quitChan) + if err != nil { + return fmt.Errorf("failed to start task interface: %w", err) + } + fmt.Println("Task incomplete - you exited early") + } + + // Show questionnaire + questions := t.Questions(ifaceType) + questionare := taskui.NewQuestionnaireModel(questions) + model, err = tea.NewProgram(questionare, tea.WithAltScreen()).Run() + if err != nil { + return err + } + if m, ok := model.(interface{ GetUserQuit() bool }); ok && m.GetUserQuit() { + return ErrUserQuit + } + + return nil +} + +func startValidationLoop(ctx context.Context, t Tasker, feedbackChan chan ValidationFeedback, doneChan chan bool, quitChan chan bool) { + ticker := time.NewTicker(1 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + ok, err := t.Validate(ctx) + feedback := ValidationFeedback{ + Success: ok, + } + if ok { + feedback.Message = "Task completed successfully!" + feedbackChan <- feedback + doneChan <- true + return + } else { + if err != nil { + feedback.Message = err.Error() + } else { + feedback.Message = "Task not yet complete" + } + feedbackChan <- feedback + } + case <-quitChan: + return + case <-ctx.Done(): + return + } + } +} diff --git a/pkg/task/task.go b/pkg/task/task.go deleted file mode 100644 index 8a82d56..0000000 --- a/pkg/task/task.go +++ /dev/null @@ -1,170 +0,0 @@ -package task - -import ( - "context" - "fmt" - "time" - - "github.com/LazyBachelor/LazyPM/internal/service" - taskui "github.com/LazyBachelor/LazyPM/pkg/task/ui" - tea "github.com/charmbracelet/bubbletea" -) - -type Tasker interface { - Init() *Task - Config() TaskConfig - - Details() taskui.TaskDetails - QuestionsFunc() QuestionsFunc - - InterfaceType() Interface - - ValidateFunc(context.Context) (ok bool, errorMsg error) - DbStateFunc(context.Context) error -} - -type Task struct { - svc *service.Services - Config TaskConfig - - Interface Interface - InterfaceType InterfaceType - - details taskui.TaskDetails - questionsFunc QuestionsFunc - - validateFunc ValidateFunc - dbStateFunc DbStateFunc - - feedbackChan chan ValidationFeedback - doneChan chan bool - quitChan chan bool -} - -func NewTask(svc *service.Services, details taskui.TaskDetails, questionsFunc QuestionsFunc) *Task { - return &Task{ - details: details, - questionsFunc: questionsFunc, - svc: svc, - } -} - -func (t *Task) IntroduceTask() error { - if t.details == (taskui.TaskDetails{}) { - return fmt.Errorf("details is not set") - } - - detailsScreen := taskui.NewTaskModel(t.details) - - model, err := tea.NewProgram(detailsScreen, tea.WithAltScreen()).Run() - if err != nil { - return err - } - if m, ok := model.(interface{ GetUserQuit() bool }); ok && m.GetUserQuit() { - return ErrUserQuit - } - return nil -} - -func (t *Task) StartInterface(ctx context.Context, cfg TaskConfig) error { - if t.Interface == nil { - return fmt.Errorf("interfaceType is not set") - } - - return t.Interface.Run(ctx, cfg) -} - -func (t *Task) Initialize(ctx context.Context) error { - if t.dbStateFunc == nil { - return fmt.Errorf("dbStateFunc is not set") - } - return t.dbStateFunc(ctx) -} - -func (t *Task) Validate(ctx context.Context) (bool, error) { - if t.validateFunc == nil { - return false, fmt.Errorf("validateFunc is not set") - } - return t.validateFunc(ctx) -} - -func (t *Task) StartQuestionnaire() error { - if t.questionsFunc == nil { - return fmt.Errorf("questionsFunc is not set") - } - - questions := t.questionsFunc(t.InterfaceType) - if questions == nil { - return fmt.Errorf("questions is nil") - } - - questionare := taskui.NewQuestionnaireModel(questions) - model, err := tea.NewProgram(questionare, tea.WithAltScreen()).Run() - if err != nil { - return err - } - if m, ok := model.(interface{ GetUserQuit() bool }); ok && m.GetUserQuit() { - return ErrUserQuit - } - return nil -} - -func (t *Task) SetConfigFunc(fn ConfigFunc) { - t.Config = fn() -} - -func (t *Task) SetInterface(interfaceType Interface) { - t.Interface = interfaceType -} - -func (t *Task) SetInterfaceType(interfaceType InterfaceType) { - t.InterfaceType = interfaceType -} - -func (t *Task) SetDbStateFunc(fn DbStateFunc) { - t.dbStateFunc = fn -} - -func (t *Task) SetValidateFunc(fn ValidateFunc) { - t.validateFunc = fn -} - -func (t *Task) SetChannels(feedbackChan chan ValidationFeedback, doneChan chan bool, quitChan chan bool) { - t.feedbackChan = feedbackChan - t.doneChan = doneChan - t.quitChan = quitChan -} - -func (t *Task) StartValidationLoop(ctx context.Context) { - ticker := time.NewTicker(1 * time.Second) - defer ticker.Stop() - - for { - select { - case <-ticker.C: - ok, err := t.Validate(ctx) - feedback := ValidationFeedback{ - Timestamp: time.Now(), - } - if ok { - feedback.Success = true - feedback.Message = "Task completed successfully!" - t.feedbackChan <- feedback - t.doneChan <- true - return - } else { - feedback.Success = false - if err != nil { - feedback.Message = err.Error() - } else { - feedback.Message = "Task not yet complete" - } - t.feedbackChan <- feedback - } - case <-t.quitChan: - return - case <-ctx.Done(): - return - } - } -} diff --git a/pkg/task/types.go b/pkg/task/types.go index a08051f..1ebab9a 100644 --- a/pkg/task/types.go +++ b/pkg/task/types.go @@ -2,15 +2,11 @@ package task import ( "context" - "errors" - "time" "github.com/LazyBachelor/LazyPM/internal/service" taskui "github.com/LazyBachelor/LazyPM/pkg/task/ui" ) -var ErrUserQuit = errors.New("user quit") - type TaskConfig = service.Config type Interface interface { @@ -25,20 +21,17 @@ const ( InterfaceWeb InterfaceType = "web" ) -type ConfigFunc func() TaskConfig -type ValidateFunc func(context.Context) (ok bool, err error) -type DbStateFunc func(context.Context) error -type QuestionsFunc func(InterfaceType) taskui.Questions - -type ValidationFeedback struct { - Success bool - Message string - Timestamp time.Time +type Tasker interface { + Config() TaskConfig + Details() taskui.TaskDetails + Questions(InterfaceType) taskui.Questions + Setup(context.Context) error + Validate(context.Context) (bool, error) } -type ValidationObserver interface { - OnValidationUpdate(feedback ValidationFeedback) - OnTaskComplete() +type ValidationFeedback struct { + Success bool + Message string } type ValidatedInterface interface {