refactor to use Tasker interface instead of Task

add tasker register for registering tasks for extiensibility
This commit is contained in:
Robin Olsen
2026-02-18 13:07:23 +01:00
parent 89f8596884
commit 23747fb799
9 changed files with 276 additions and 300 deletions

View File

@@ -46,7 +46,7 @@ func runStartCmd(cmd *cobra.Command, args []string) error {
} }
defer cleanup() defer cleanup()
tasks := initTasks(svc) surveyTasks := initTasks(svc)
if cmd.Flags().Changed("interface") { if cmd.Flags().Changed("interface") {
if _, ok := interfaces[interfaceType]; !ok { if _, ok := interfaces[interfaceType]; !ok {
@@ -58,10 +58,10 @@ func runStartCmd(cmd *cobra.Command, args []string) error {
} }
if cmd.Flags().Changed("stage") { if cmd.Flags().Changed("stage") {
if stage < 1 || stage > len(tasks) { if stage < 1 || stage > len(surveyTasks) {
return fmt.Errorf("invalid stage") 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 err
} }
return nil return nil
@@ -71,8 +71,6 @@ func runStartCmd(cmd *cobra.Command, args []string) error {
return returnIfUserQuit(err, "failed to run intro") return returnIfUserQuit(err, "failed to run intro")
} }
surveyTasks := initTasks(svc)
if err := taskLoop(cmd.Context(), surveyTasks, interfaces); err != nil { if err := taskLoop(cmd.Context(), surveyTasks, interfaces); err != nil {
return returnIfUserQuit(err, "task loop failed") return returnIfUserQuit(err, "task loop failed")
} }

View File

@@ -3,12 +3,13 @@ package main
import ( import (
"context" "context"
"github.com/LazyBachelor/LazyPM/cmd/survey/tasks"
"github.com/LazyBachelor/LazyPM/internal/service" "github.com/LazyBachelor/LazyPM/internal/service"
"github.com/LazyBachelor/LazyPM/pkg/cli/repl" "github.com/LazyBachelor/LazyPM/pkg/cli/repl"
"github.com/LazyBachelor/LazyPM/pkg/task" "github.com/LazyBachelor/LazyPM/pkg/task"
"github.com/LazyBachelor/LazyPM/pkg/tui" "github.com/LazyBachelor/LazyPM/pkg/tui"
"github.com/LazyBachelor/LazyPM/pkg/web" "github.com/LazyBachelor/LazyPM/pkg/web"
_ "github.com/LazyBachelor/LazyPM/cmd/survey/tasks"
) )
func initializeServices(ctx context.Context) (*service.Services, func(), error) { 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) 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 { func initInterfaces() map[string]task.Interface {
return map[string]task.Interface{ return map[string]task.Interface{
"repl": repl.NewRepl(), "repl": repl.NewRepl(),
@@ -34,3 +29,17 @@ func initInterfaces() map[string]task.Interface {
"web": web.NewWeb(), "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
}

View File

@@ -10,56 +10,11 @@ import (
"github.com/LazyBachelor/LazyPM/pkg/task" "github.com/LazyBachelor/LazyPM/pkg/task"
) )
func runTask(ctx context.Context, t *task.Task, i task.Interface) error { func runTask(ctx context.Context, t task.Tasker, i task.Interface) error {
t.SetInterface(i) return task.RunTask(ctx, t, i, tasks.InterfaceToType(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 { func taskLoop(ctx context.Context, surveyTasks []task.Tasker, interfaces map[string]task.Interface) error {
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 taskLoop(ctx context.Context, surveyTasks []*task.Task, interfaces map[string]task.Interface) error {
var ifaceNames []string var ifaceNames []string
for name := range interfaces { for name := range interfaces {
ifaceNames = append(ifaceNames, name) 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] ifaceNames[i], ifaceNames[j] = ifaceNames[j], ifaceNames[i]
}) })
for i, task := range surveyTasks { for i, t := range surveyTasks {
idx := i % len(ifaceNames) idx := i % len(ifaceNames)
selected := interfaces[ifaceNames[idx]] selected := interfaces[ifaceNames[idx]]
if err := runTask(ctx, task, selected); err != nil { if err := runTask(ctx, t, selected); err != nil {
return err return err
} }
} }
@@ -81,7 +36,7 @@ func taskLoop(ctx context.Context, surveyTasks []*task.Task, interfaces map[stri
} }
func returnIfUserQuit(err error, msg string) error { func returnIfUserQuit(err error, msg string) error {
if errors.Is(err, ErrUserQuit) { if errors.Is(err, task.ErrUserQuit) {
return nil return nil
} }
return fmt.Errorf("%s: %w", msg, err) return fmt.Errorf("%s: %w", msg, err)

View File

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

View File

@@ -7,7 +7,7 @@ import (
"github.com/LazyBachelor/LazyPM/internal/models" "github.com/LazyBachelor/LazyPM/internal/models"
"github.com/LazyBachelor/LazyPM/internal/service" "github.com/LazyBachelor/LazyPM/internal/service"
"github.com/LazyBachelor/LazyPM/pkg/task" "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" "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. 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.` 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 { type CreateIssueTask struct {
*task.Task
svc *service.Services svc *service.Services
} }
func NewCreateIssueTask(svc *service.Services) *CreateIssueTask { func NewCreateIssueTask(svc *service.Services) *CreateIssueTask {
return &CreateIssueTask{ return &CreateIssueTask{svc: svc}
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
} }
func (t *CreateIssueTask) Config() task.TaskConfig { func (t *CreateIssueTask) Config() task.TaskConfig {
@@ -45,45 +40,30 @@ func (t *CreateIssueTask) Config() task.TaskConfig {
} }
} }
func (t *CreateIssueTask) Details() ui.TaskDetails { func (t *CreateIssueTask) Details() taskui.TaskDetails {
return ui.TaskDetails{ return taskui.TaskDetails{
Title: "Create Issue Task", 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", TimeToComplete: "15m",
Difficulty: "Hard", Difficulty: "Hard",
} }
} }
func (t *CreateIssueTask) QuestionsFunc() task.QuestionsFunc { func (t *CreateIssueTask) Questions(interfaceType task.InterfaceType) taskui.Questions {
return func(interfaceType task.InterfaceType) ui.Questions { return taskui.Questions{
questions := ui.Questions{} huh.NewGroup(huh.NewConfirm().Title("Was this good")),
// Add an extra question for TUI
if interfaceType == task.InterfaceTUI {
questions = append(questions,
huh.NewGroup( huh.NewGroup(
huh.NewConfirm().Title("Did you complete?"), huh.NewSelect[int]().
), Options(
)
}
questions = append(questions,
huh.NewGroup(
huh.NewConfirm().Title("Was this good"),
),
huh.NewGroup(
huh.NewSelect[int]().Options(
huh.NewOption("Very good", 1), huh.NewOption("Very good", 1),
huh.NewOption("Very Bad", 2), huh.NewOption("Very Bad", 2),
).Title("How good was it?"), ).
Title("How good was it?"),
), ),
)
return questions
} }
} }
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 // Clear existing issues to ensure a clean state for the task
if err := t.svc.DeleteIssues(); err != nil { if err := t.svc.DeleteIssues(); err != nil {
return err return err
@@ -91,19 +71,16 @@ func (t *CreateIssueTask) DbStateFunc(ctx context.Context) error {
issue := models.Issue{ issue := models.Issue{
ID: "pm-abc", ID: "pm-abc",
Title: "Create A New Issue", Description: description, Title: "Create A New Issue",
IssueType: models.TypeTask, Status: models.StatusOpen, Description: description,
IssueType: models.TypeTask,
Status: models.StatusOpen,
} }
if err := t.svc.Beads.CreateIssue(ctx, &issue, ""); err != nil { return t.svc.Beads.CreateIssue(ctx, &issue, "")
return err
} }
return nil func (t *CreateIssueTask) Validate(ctx context.Context) (bool, error) {
}
func (t *CreateIssueTask) ValidateFunc(ctx context.Context) (ok bool, errorMsg error) {
// Fetches issues, indexed with latest first
issues, err := t.svc.Beads.SearchIssues(ctx, "", models.IssueFilter{}) issues, err := t.svc.Beads.SearchIssues(ctx, "", models.IssueFilter{})
if err != nil { if err != nil {
return false, err return false, err

32
pkg/task/register.go Normal file
View File

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

111
pkg/task/runner.go Normal file
View File

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

View File

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

View File

@@ -2,15 +2,11 @@ package task
import ( import (
"context" "context"
"errors"
"time"
"github.com/LazyBachelor/LazyPM/internal/service" "github.com/LazyBachelor/LazyPM/internal/service"
taskui "github.com/LazyBachelor/LazyPM/pkg/task/ui" taskui "github.com/LazyBachelor/LazyPM/pkg/task/ui"
) )
var ErrUserQuit = errors.New("user quit")
type TaskConfig = service.Config type TaskConfig = service.Config
type Interface interface { type Interface interface {
@@ -25,20 +21,17 @@ const (
InterfaceWeb InterfaceType = "web" InterfaceWeb InterfaceType = "web"
) )
type ConfigFunc func() TaskConfig type Tasker interface {
type ValidateFunc func(context.Context) (ok bool, err error) Config() TaskConfig
type DbStateFunc func(context.Context) error Details() taskui.TaskDetails
type QuestionsFunc func(InterfaceType) taskui.Questions Questions(InterfaceType) taskui.Questions
Setup(context.Context) error
Validate(context.Context) (bool, error)
}
type ValidationFeedback struct { type ValidationFeedback struct {
Success bool Success bool
Message string Message string
Timestamp time.Time
}
type ValidationObserver interface {
OnValidationUpdate(feedback ValidationFeedback)
OnTaskComplete()
} }
type ValidatedInterface interface { type ValidatedInterface interface {