diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..50540f4 --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +# Make a copy of this file and name it .env, then fill in the values below. +DEV=True +DB_URI= +DB_USER= +DB_PASSWORD= diff --git a/.gitignore b/.gitignore index 5eaa42c..c7d63c5 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ package.json *.db *.ext +.env # Added by goreleaser init: dist/ diff --git a/README.md b/README.md index a71b87a..acbe0df 100644 --- a/README.md +++ b/README.md @@ -54,20 +54,27 @@ choco install make ## Build Commands + ```bash -# Build all binaries -make build # Creates bin/pm, bin/tui, bin/web, bin/survey +# Run web (without hot reload) +make web -# Run specific interfaces -make cli # Run CLI interface -make tui # Run TUI interface (interactive) -make web # Run Web server (localhost:8080) -make start # Run survey interface - -# Development with hot reload +# Web with hot reload make dev # Watch templ files and auto-reload web make tw # Watch Tailwind CSS changes +# Run tui +make tui + +# Run cli +go run ./cmd/cli + +# Run a specific task with a specific interface (e.g. WEB UI and priority_management) +go run ./cmd/pm survey start -i web -t priority_management + +# Build all binaries +make build # Creates bin/pm, bin/tui, bin/web, bin/survey + # Docker (lazyos desktop environment) make os-build # Build lazyos Docker image make os-run # Run lazyos container (localhost:3000-3001) @@ -194,4 +201,4 @@ See `.github/workflows/` for details. ## License -See [LICENSE](LICENSE) file for details. \ No newline at end of file +See [LICENSE](LICENSE) file for details. diff --git a/cmd/pm/init.go b/cmd/pm/init.go index 1ed43df..bf637bb 100644 --- a/cmd/pm/init.go +++ b/cmd/pm/init.go @@ -8,56 +8,44 @@ import ( "github.com/LazyBachelor/LazyPM/internal/app" "github.com/LazyBachelor/LazyPM/internal/commands/issues" "github.com/LazyBachelor/LazyPM/internal/commands/survey" + "github.com/LazyBachelor/LazyPM/internal/models" "github.com/LazyBachelor/LazyPM/pkg/repl" "github.com/LazyBachelor/LazyPM/pkg/task" "github.com/LazyBachelor/LazyPM/pkg/tui" "github.com/LazyBachelor/LazyPM/pkg/web" + "github.com/joho/godotenv" "github.com/spf13/cobra" ) func init() { + godotenv.Load(".env") + + models.BaseConfig = models.BaseConfig.LoadFromEnv() + task.RegisterInterface("tui", tui.New()) task.RegisterInterface("web", web.New()) task.RegisterInterface("repl", repl.New()) + task.RegisterTask("backlog_refinement", func(app *app.App) task.Tasker { + return tasks.NewBacklogRefinementTask(app) + }) task.RegisterTask("create_issue", func(app *app.App) task.Tasker { return tasks.NewCreateIssueTask(app) }) task.RegisterTask("coding_task", func(app *app.App) task.Tasker { return tasks.NewCodingTask(app) }) - task.RegisterTask("git_task", func(app *app.App) task.Tasker { - return tasks.NewGitTask(app) - }) task.RegisterTask("sprint_planning", func(app *app.App) task.Tasker { return tasks.NewSprintPlanningTask(app) }) - task.RegisterTask("issue_triage", func(app *app.App) task.Tasker { - return tasks.NewIssueTriageTask(app) - }) - task.RegisterTask("issue_review_cleanup", func(app *app.App) task.Tasker { - return tasks.NewIssueReviewCleanupTask(app) - }) - task.RegisterTask("milestone_tracking", func(app *app.App) task.Tasker { - return tasks.NewMilestoneTrackingTask(app) - }) - task.RegisterTask("dependency_management", func(app *app.App) task.Tasker { - return tasks.NewDependencyManagementTask(app) - }) - task.RegisterTask("team_capacity", func(app *app.App) task.Tasker { - return tasks.NewTeamCapacityTask(app) - }) - task.RegisterTask("report_generation", func(app *app.App) task.Tasker { - return tasks.NewReportGenerationTask(app) - }) - task.RegisterTask("stakeholder_update", func(app *app.App) task.Tasker { - return tasks.NewStakeholderUpdateTask(app) - }) task.RegisterTask("priority_management", func(app *app.App) task.Tasker { return tasks.NewPriorityManagementTask(app) }) - task.RegisterTask("backlog_refinement", func(app *app.App) task.Tasker { - return tasks.NewBacklogRefinementTask(app) + task.RegisterTask("issue_review_cleanup", func(app *app.App) task.Tasker { + return tasks.NewIssueReviewCleanupTask(app) + }) + task.RegisterTask("git_task", func(app *app.App) task.Tasker { + return tasks.NewGitTask(app) }) cobra.EnableCommandSorting = false @@ -172,6 +160,8 @@ func commandNeedsApp(cmd *cobra.Command) bool { func ensureAppInitialized(ctx context.Context) error { if App != nil { + survey.SetApp(App) + issues.SetApp(App) return nil } diff --git a/cmd/pm/intro.go b/cmd/pm/intro.go index 7bc2547..7d92203 100644 --- a/cmd/pm/intro.go +++ b/cmd/pm/intro.go @@ -38,34 +38,35 @@ type keyMap struct { Quit key.Binding } -var keys = keyMap{ - Start: key.NewBinding( - key.WithKeys("enter"), - key.WithHelp("enter", "start survey"), - ), - Continue: key.NewBinding( - key.WithKeys(" ", "j", "l", "down", "right"), - key.WithHelp("space", "continue"), - ), - Back: key.NewBinding( - key.WithKeys("b", "k", "h", "backspace", "up", "left"), - key.WithHelp("b", "back"), - ), - Quit: key.NewBinding( - key.WithKeys("esc", "ctrl+c", "q"), - key.WithHelp("esc", "quit"), - ), -} - type introModel struct { stage int width, height int userQuit bool + keys keyMap } func newIntroModel() introModel { + var keys = keyMap{ + Start: key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "start survey"), + ), + Continue: key.NewBinding( + key.WithKeys(" ", "j", "l", "down", "right"), + key.WithHelp("space", "continue"), + ), + Back: key.NewBinding( + key.WithKeys("b", "k", "h", "backspace", "up", "left"), + key.WithHelp("b", "back"), + ), + Quit: key.NewBinding( + key.WithKeys("esc", "ctrl+c", "q"), + key.WithHelp("esc", "quit"), + ), + } return introModel{ stage: 1, + keys: keys, } } @@ -90,18 +91,18 @@ func (m introModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.SetSize(msg.Width, msg.Height) case tea.KeyMsg: switch { - case key.Matches(msg, keys.Start) && m.stage == stages: + case key.Matches(msg, m.keys.Start) && m.stage == stages: return m, tea.Quit - case key.Matches(msg, keys.Continue): + case key.Matches(msg, m.keys.Continue): if m.stage < stages { m.stage++ } - case key.Matches(msg, keys.Back): + case key.Matches(msg, m.keys.Back): if m.stage > 1 { m.stage-- } return m, nil - case key.Matches(msg, keys.Quit): + case key.Matches(msg, m.keys.Quit): m.userQuit = true return m, tea.Quit } @@ -139,11 +140,11 @@ func (m introModel) View() string { b.WriteString(boxStyle.Render(style.TextStyle.Render(content))) b.WriteString("\n") - helpText := "Press " + keys.Continue.Help().Key + " to continue • " + - keys.Back.Help().Key + " to go back • " + keys.Quit.Help().Key + " to quit" + helpText := "Press " + m.keys.Continue.Help().Key + " to continue • " + + m.keys.Back.Help().Key + " to go back • " + m.keys.Quit.Help().Key + " to quit" if m.stage == stages { - helpText += "\nPress " + keys.Start.Help().Key + " to start the survey" + helpText += "\nPress " + m.keys.Start.Help().Key + " to start the survey" } b.WriteString(style.HelpStyle.Render(helpText)) diff --git a/cmd/pm/intro_questionnare.go b/cmd/pm/intro_questionnare.go new file mode 100644 index 0000000..86fdf72 --- /dev/null +++ b/cmd/pm/intro_questionnare.go @@ -0,0 +1,104 @@ +package main + +import ( + "github.com/LazyBachelor/LazyPM/pkg/task" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/huh" +) + +type IntroQuestionnaire struct{} + +func newIntroQuestionnaire() *IntroQuestionnaire { + return &IntroQuestionnaire{} +} + +func (iq *IntroQuestionnaire) Run() (map[string]any, error) { + model := task.NewQuestionnaireModel(iq.Questions(), iq.Keys()) + app := tea.NewProgram(model, tea.WithAltScreen()) + + m, err := app.Run() + if err != nil { + return nil, err + } + + if q, ok := m.(*task.QuestionnaireModel); ok { + if q.GetUserQuit() { + return nil, task.ErrUserQuit + } + return q.GetAnswers(), nil + } + + return nil, nil +} + +func (iq *IntroQuestionnaire) Questions() task.Questions { + return task.Questions{ + huh.NewGroup( + huh.NewSelect[string](). + Title("Which age group do you belong to?"). + Description("This helps us understand the background of participants."). + Options( + huh.NewOption("Under 18", "under_18"), + huh.NewOption("18–24", "18_24"), + huh.NewOption("25–34", "25_34"), + huh.NewOption("35–44", "35_44"), + huh.NewOption("45+", "45_plus"), + ). + Key("age_group"), + ), + huh.NewGroup( + huh.NewSelect[string](). + Title("Are you a student, employed, or both?"). + Description("This helps us understand your current situation."). + Options( + huh.NewOption("Student", "student"), + huh.NewOption("Employed", "employed"), + huh.NewOption("Both student and employed", "both"), + huh.NewOption("Neither", "neither"), + ). + Key("occupation_status"), + ), + huh.NewGroup( + huh.NewSelect[string](). + Title("How far along are you in your education?"). + Description("Select the option that best matches your current level."). + Options( + huh.NewOption("Primary school", "primary"), + huh.NewOption("Secondary school", "secondary"), + huh.NewOption("Bachelor's degree", "bachelor"), + huh.NewOption("Master's degree", "master"), + huh.NewOption("PhD / Doctorate", "phd"), + huh.NewOption("Other", "other"), + ). + Key("education_level"), + ), + huh.NewGroup( + huh.NewSelect[string](). + Title("How would you describe your experience with the command line?"). + Description("This helps us tailor the questions to your experience level."). + Options( + huh.NewOption("No experience", "none"), + huh.NewOption("Some experience", "some"), + huh.NewOption("Extensive experience", "extensive"), + ). + Key("cli_experience"), + ), + huh.NewGroup( + huh.NewSelect[string](). + Title("How often do you use the command line?"). + Description("Select the option that best describes your usage."). + Options( + huh.NewOption("Never", "never"), + huh.NewOption("Rarely", "rarely"), + huh.NewOption("Weekly", "weekly"), + huh.NewOption("Several times a week", "multiple_weekly"), + huh.NewOption("Daily", "daily"), + ). + Key("cli_frequency"), + ), + } +} + +func (iq *IntroQuestionnaire) Keys() []string { + return []string{"age_group", "occupation_status", "education_level", "cli_experience", "cli_frequency"} +} diff --git a/cmd/pm/runner.go b/cmd/pm/runner.go index 1c14bae..98c69d3 100644 --- a/cmd/pm/runner.go +++ b/cmd/pm/runner.go @@ -5,10 +5,13 @@ import ( "errors" "fmt" "math/rand" + "time" "github.com/LazyBachelor/LazyPM/cmd/pm/tasks" "github.com/LazyBachelor/LazyPM/internal/commands/survey" + "github.com/LazyBachelor/LazyPM/internal/storage" "github.com/LazyBachelor/LazyPM/pkg/task" + "github.com/charmbracelet/huh" "github.com/spf13/cobra" ) @@ -28,6 +31,79 @@ func runStartCmd(cmd *cobra.Command, args []string) error { return fmt.Errorf("application services are not available") } + if !cmd.Flags().Changed("dev") { + var mongoStorage *storage.MongoStorage + var continueWithoutSubmitting bool + + for { + + if app.Config.DbUri == "" { + cmd.Println("No database URI provided in environment, survey responses will not be submitted.") + break + } + + db, err := storage.NewMongoStorageInteractive(cmd.Context(), app.Config.DbUri) + if err == nil { + mongoStorage = db + break + } + + cmd.Println("Failed to connect to database, survey responses will not be submitted.") + + if err := huh.NewConfirm(). + Title("Do you want to continue without submitting your responses?"). + Description("You can fix your database connection and submit your responses later with the submit command."). + Value(&continueWithoutSubmitting). + WithTheme(huh.ThemeBase16()). + RunAccessible(cmd.OutOrStdout(), cmd.InOrStdin()); err != nil { + return fmt.Errorf("failed to read user input: %w", err) + } + + if continueWithoutSubmitting { + break + } + } + + if mongoStorage != nil { + cmd.Println("Connected to Database Successfully. Starting survey...") + time.Sleep(2 * time.Second) + + defer mongoStorage.Close() + + ctx := cmd.Context() + + go func() { + ticker := time.NewTicker(10 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if err := mongoStorage.SubmitSurveyResponsesCmd(ctx, app.Config.AppDir); err != nil { + cmd.Printf("Failed to submit survey responses: %v\n", err) + } + } + } + }() + + if err := mongoStorage.SubmitSurveyResponsesCmd(ctx, app.Config.AppDir); err != nil { + cmd.Printf("Failed to submit survey responses: %v\n", err) + } + + defer func() { + if err := mongoStorage.SubmitSurveyResponsesCmd(context.Background(), app.Config.AppDir); err != nil { + cmd.Printf("Failed to submit survey responses on shutdown: %v\n", err) + } + }() + + } else { + cmd.Println("Starting survey without database connection. Your responses will not be submitted...") + time.Sleep(2 * time.Second) + } + } + interfaces := initInterfaces() surveyTasks := initTasks(app) @@ -49,9 +125,21 @@ func runStartCmd(cmd *cobra.Command, args []string) error { survey.Task: surveyTasks[survey.Task], } } + if !cmd.Flags().Changed("dev") { + if err := newIntroModel().Run(); err != nil { + return returnIfUserQuit(err, "failed to run intro") + } - if err := newIntroModel().Run(); err != nil { - return returnIfUserQuit(err, "failed to run intro") + introAnswers, err := newIntroQuestionnaire().Run() + if err != nil { + return returnIfUserQuit(err, "failed to run intro questionnaire") + } + + if app != nil && app.Stats != nil && introAnswers != nil { + if err := app.Stats.RecordIntroQuestionnaireAnswers(introAnswers); err != nil { + cmd.Printf("Failed to record intro questionnaire answers: %v\n", err) + } + } } if err := taskLoop(cmd.Context(), app, surveyTasks, interfaces); err != nil { @@ -70,7 +158,8 @@ func taskLoop(ctx context.Context, application *task.App, surveyTasks map[string return fmt.Errorf("no interfaces are available") } - if len(surveyTasks) == 0 { + taskNames := task.ListTasks() + if len(taskNames) == 0 { return fmt.Errorf("no tasks are available") } @@ -79,10 +168,18 @@ func taskLoop(ctx context.Context, application *task.App, surveyTasks map[string }) idx := 0 - for _, t := range surveyTasks { + for _, taskName := range taskNames { + t, ok := surveyTasks[taskName] + if !ok { + continue + } iIdx := idx % len(iNames) selected := interfaces[iNames[iIdx]] + if selected == nil { + return fmt.Errorf("interface %q is nil (available: %v)", iNames[iIdx], iNames) + } + runner := task.NewTaskRunner(application) if err := runner.Run(ctx, t, selected, tasks.InterfaceToType(selected)); err != nil { diff --git a/cmd/pm/tasks/backlogRefinement.go b/cmd/pm/tasks/backlogRefinement.go index be536b5..61be858 100644 --- a/cmd/pm/tasks/backlogRefinement.go +++ b/cmd/pm/tasks/backlogRefinement.go @@ -2,6 +2,7 @@ package tasks import ( "context" + "strings" "github.com/LazyBachelor/LazyPM/internal/models" "github.com/LazyBachelor/LazyPM/internal/utils/check" @@ -12,12 +13,12 @@ const backlogRefinementDescription = `You are tasked with backlog refinement. The product backlog has become cluttered with old and unclear issues. You need to groom the backlog: -1. Review all issues in the backlog -2. Identify stale or obsolete issues (older items that are no longer relevant) -3. Update issue descriptions for clarity where needed -4. Close issues that are duplicates or no longer applicable -5. Reprioritize issues based on current business value -6. Ensure remaining issues are well-defined and actionable +1. Go to the backlog. +2. Find two issues that got the same name or describe the same problem. +3. Open one of these issues. +4. Select "Close issue" +5. Choose "Duplicate issue" as closing reason. +6. Save/close issue. Focus on making the backlog a reliable source of upcoming work.` @@ -35,8 +36,8 @@ func (t *BacklogRefinementTask) Config() Config { return BaseConfig().WithStatisticsStoragePath("./.pm/refinement-task-stats.json") } -func (t *BacklogRefinementTask) Details() TaskDetails { - return BaseDetails(). +func (t *BacklogRefinementTask) Details(interfaceType InterfaceType) TaskDetails { + return BaseDetails(interfaceType). WithTitle("Backlog Refinement Task"). WithDescription(backlogRefinementDescription). WithTimeToComplete("12m"). @@ -47,60 +48,65 @@ func (t *BacklogRefinementTask) Questions(interfaceType InterfaceType) Questions return BaseQuestions(interfaceType).With( huh.NewGroup( huh.NewSelect[int](). - Title("How many issues did you close or update during refinement?"). + Key("how-many-duplicate-issues"). + Title("How many duplicate issues did you close during refinement?"). Options( - huh.NewOption("1-2", 1), - huh.NewOption("3-4", 2), - huh.NewOption("5+", 3), + huh.NewOption("1", 1), + huh.NewOption("2", 2), + huh.NewOption("3+", 3), ), ), ) } +func (t *BacklogRefinementTask) QuestionnaireKeys(_ InterfaceType) []string { + return []string{"task_completed", "task_difficulty", "how-many-duplicate-issues"} +} + func (t *BacklogRefinementTask) Setup(ctx context.Context) error { if err := ClearIssues(t.app); err != nil { return err } refinementIssues := []*models.Issue{ - NewIssueBuilder(). - WithTitle("Old feature request: Fax integration"). - WithDescription("Allow sending reports via fax. DEPRECATED - nobody uses fax anymore"). - WithPriority(3). - WithStatus(models.StatusOpen). - WithIssueType(models.TypeTask). - Build(), NewIssueBuilder(). WithTitle("User profile page"). - WithDescription("Create page for users to view profile. DUPLICATE of user-management epic"). + WithDescription("Create page for users to view and edit their profile"). WithPriority(2). WithStatus(models.StatusOpen). WithIssueType(models.TypeTask). Build(), NewIssueBuilder(). - WithTitle("Mobile app redesign"). - WithDescription("Redesign mobile interface with modern UI patterns. Still relevant, needs clarity"). + WithTitle("User profile page"). + WithDescription("Allow users to view their profile information"). + WithPriority(2). + WithStatus(models.StatusOpen). + WithIssueType(models.TypeTask). + Build(), + NewIssueBuilder(). + WithTitle("Fix login timeout"). + WithDescription("Login sometimes times out after 30 seconds"). WithPriority(1). WithStatus(models.StatusOpen). - WithIssueType(models.TypeTask). + WithIssueType(models.TypeBug). Build(), NewIssueBuilder(). - WithTitle("Legacy data export tool"). - WithDescription("Tool for exporting data in old format. OBSOLETE - format no longer supported"). - WithPriority(3). + WithTitle("Fix login timeout"). + WithDescription("Users report login requests timing out"). + WithPriority(1). WithStatus(models.StatusOpen). - WithIssueType(models.TypeTask). + WithIssueType(models.TypeBug). Build(), NewIssueBuilder(). - WithTitle("API v1 documentation"). - WithDescription("Document old API version. DEPRECATED - migrating to v2"). - WithPriority(3). + WithTitle("Mobile app redesign"). + WithDescription("Redesign mobile interface with modern UI patterns"). + WithPriority(2). WithStatus(models.StatusOpen). WithIssueType(models.TypeTask). Build(), NewIssueBuilder(). WithTitle("Customer feedback system"). - WithDescription("Build system for collecting user feedback. HIGH VALUE - prioritize"). + WithDescription("Build system for collecting user feedback"). WithPriority(2). WithStatus(models.StatusOpen). WithIssueType(models.TypeTask). @@ -122,5 +128,22 @@ func (t *BacklogRefinementTask) Setup(ctx context.Context) error { func (t *BacklogRefinementTask) Validate(ctx context.Context) ValidationFeedback { expect := check.NewExpector() + issues, err := FetchIssues(ctx, t.app, t.setupIssue) + if err != nil { + return expect.ValidationFeedback + } + + var closedDuplicate *models.Issue + for _, issue := range issues { + if issue.Status == models.StatusClosed && + strings.Contains(strings.ToLower(issue.CloseReason), "duplicate") { + closedDuplicate = issue + break + } + } + + expect.Assert(closedDuplicate != nil, + "Expected one duplicate issue to be closed with 'Duplicate issue' as closing reason") + return expect.Complete() } diff --git a/cmd/pm/tasks/base.go b/cmd/pm/tasks/base.go index ca78479..4c5c4d5 100644 --- a/cmd/pm/tasks/base.go +++ b/cmd/pm/tasks/base.go @@ -51,12 +51,37 @@ func InterfaceToType(it Interface) InterfaceType { } } -func BaseDetails() TaskDetails { +func BaseDetails(interfaceType InterfaceType) TaskDetails { + + var interfaceDesc string + + switch interfaceType { + case InterfaceTypeREPL: + interfaceDesc = `How to use the REPL Interface +- The REPL interface allows you to interact with the task using a command-line interface. +- You can type commands to perform actions related to the task, such as creating issues, updating statuses, etc. +- The interface will provide prompts and feedback based on your inputs.` + case InterfaceTypeTUI: + interfaceDesc = `How to use the TUI Interface +- The TUI (Text User Interface) provides a more interactive experience in the terminal. +- You can navigate through menus, select options, and view task details in a structured format. +- Use keyboard shortcuts to perform actions and explore different sections of the interface.` + case InterfaceTypeWeb: + interfaceDesc = `How to use the Web Interface +- The Web interface allows you to interact with the task through a web browser. +- You can access the interface by navigating to the provided URL. +- The interface will have buttons, forms, and other interactive elements to help you complete the task.` + default: + interfaceDesc = "Unknown Interface" + } + return TaskDetails{ - Title: "Base Task", - Description: "This is a base task.", - TimeToComplete: "10m", - Difficulty: "Easy", + Title: "Base Task", + Description: "This is a base task.", + TimeToComplete: "10m", + Difficulty: "Easy", + InterfaceType: interfaceType, + InterfaceDescription: interfaceDesc, } } diff --git a/cmd/pm/tasks/codingTask.go b/cmd/pm/tasks/codingTask.go index 754f94c..d2a4f3a 100644 --- a/cmd/pm/tasks/codingTask.go +++ b/cmd/pm/tasks/codingTask.go @@ -84,8 +84,8 @@ func (t *CodingTask) Config() Config { return BaseConfig().WithStatisticsStoragePath("./.pm/coding-task-stats.json") } -func (t *CodingTask) Details() TaskDetails { - return BaseDetails().WithTitle("Coding Task").WithDescription(codingDescription) +func (t *CodingTask) Details(interfaceType InterfaceType) TaskDetails { + return BaseDetails(interfaceType).WithTitle("Coding Task").WithDescription(codingDescription) } func (t *CodingTask) Questions(interfaceType InterfaceType) (questions Questions) { diff --git a/cmd/pm/tasks/createIssue.go b/cmd/pm/tasks/createIssue.go index a600c47..6f0b7e5 100644 --- a/cmd/pm/tasks/createIssue.go +++ b/cmd/pm/tasks/createIssue.go @@ -34,8 +34,8 @@ func (t *CreateIssueTask) Config() Config { return BaseConfig().WithStatisticsStoragePath("./.pm/create-issue-stats.json") } -func (t *CreateIssueTask) Details() TaskDetails { - return BaseDetails().WithTitle("Create Issue Task").WithDescription(description) +func (t *CreateIssueTask) Details(interfaceType InterfaceType) TaskDetails { + return BaseDetails(interfaceType).WithTitle("Create Issue Task").WithDescription(description) } func (t *CreateIssueTask) Questions(interfaceType InterfaceType) Questions { diff --git a/cmd/pm/tasks/dependencyManagement.go b/cmd/pm/tasks/dependencyManagement.go index c7c92f3..f4b521f 100644 --- a/cmd/pm/tasks/dependencyManagement.go +++ b/cmd/pm/tasks/dependencyManagement.go @@ -2,6 +2,7 @@ package tasks import ( "context" + "fmt" "github.com/LazyBachelor/LazyPM/internal/models" "github.com/LazyBachelor/LazyPM/internal/utils/check" @@ -12,11 +13,11 @@ const dependencyManagementDescription = `You are tasked with managing issue depe Several issues in your project have dependencies on other issues. You need to: -1. Review the dependency chain described in issue descriptions -2. Identify issues that are blocked by others -3. Prioritize work on foundational issues (those that unblock others) -4. Update issue statuses to reflect dependency resolution -5. Ensure no circular dependencies exist +1. Find the 4 issues that mention dependencies in their detail description. For example: "Depends on Issue '123'". Set their status to "blocked". +2. Find the 2 foundational issues that are mentioned by the other issues. +3. Set priority of the 2 foundational issues to 3 (high). +4. Set status of the 2 foundational issues to in-progress. +5. Assign the 2 foundational issues to yourself as "Me". Resolving dependencies in the right order is critical for efficient team workflow.` @@ -24,6 +25,7 @@ type DependencyManagementTask struct { done bool app *App setupIssue *Issue + depIssues []*Issue } func NewDependencyManagementTask(app *App) *DependencyManagementTask { @@ -34,8 +36,8 @@ func (t *DependencyManagementTask) Config() Config { return BaseConfig().WithStatisticsStoragePath("./.pm/dependency-task-stats.json") } -func (t *DependencyManagementTask) Details() TaskDetails { - return BaseDetails(). +func (t *DependencyManagementTask) Details(interfaceType InterfaceType) TaskDetails { + return BaseDetails(interfaceType). WithTitle("Dependency Management Task"). WithDescription(dependencyManagementDescription). WithTimeToComplete("15m"). @@ -61,45 +63,52 @@ func (t *DependencyManagementTask) Setup(ctx context.Context) error { return err } - depIssues := []*Issue{ + t.depIssues = []*Issue{ NewIssueBuilder(). WithTitle("Setup database connection"). WithDescription("Configure database connection pool."). - WithPriority(1). + WithPriority(2). WithStatus(models.StatusOpen). WithIssueType(models.TypeTask). Build(), NewIssueBuilder(). - WithTitle("Define API contract"). - WithDescription("Create OpenAPI spec."). - WithPriority(1). + WithTitle("Create home page for the website"). + WithDescription("Create a page for the website."). + WithPriority(2). WithStatus(models.StatusOpen). WithIssueType(models.TypeTask). Build(), NewIssueBuilder(). - WithTitle("Implement user repository"). - WithDescription("Implement data access layer for user management."). + WithTitle("Implement Authentication System"). + WithDescription("Add login/logout functionality. Depends on 'Setup database connection' issue."). WithPriority(2). - WithStatus(models.StatusBlocked). + WithStatus(models.StatusOpen). WithIssueType(models.TypeTask). Build(), NewIssueBuilder(). - WithTitle("Create user endpoints"). - WithDescription("REST API for users."). - WithPriority(2). - WithStatus(models.StatusBlocked). - WithIssueType(models.TypeTask). - Build(), - NewIssueBuilder(). - WithTitle("Build user profile UI"). - WithDescription("Frontend user profile page."). + WithTitle("Add user management operations"). + WithDescription("Add operations for user management. Depends on 'Setup database connection' issue."). WithPriority(3). - WithStatus(models.StatusBlocked). + WithStatus(models.StatusOpen). + WithIssueType(models.TypeTask). + Build(), + NewIssueBuilder(). + WithTitle("Create user profile page"). + WithDescription("Frontend user profile page. Depends on 'Create home page for the website' issue."). + WithPriority(3). + WithStatus(models.StatusOpen). + WithIssueType(models.TypeTask). + Build(), + NewIssueBuilder(). + WithTitle("Create about page"). + WithDescription("Frontend about page. Depends on 'Create home page for the website' issue."). + WithPriority(2). + WithStatus(models.StatusOpen). WithIssueType(models.TypeTask). Build(), } - if err := t.app.Issues.CreateIssues(ctx, depIssues, ""); err != nil { + if err := t.app.Issues.CreateIssues(ctx, t.depIssues, ""); err != nil { return err } @@ -114,5 +123,39 @@ func (t *DependencyManagementTask) Setup(ctx context.Context) error { func (t *DependencyManagementTask) Validate(ctx context.Context) ValidationFeedback { expect := check.NewExpector() + taskIssue := t.setupIssue + issues, err := FetchIssues(ctx, t.app, t.setupIssue) + if err != nil { + return expect.Fatal("Could not fetch issues") + } + + for _, issue := range issues { + for _, depIssue := range t.depIssues[2:] { + if issue.Title == depIssue.Title { + expect.Equal(issue.Status, models.StatusBlocked, + fmt.Sprintf("%s status", issue.Title)) + } + } + + for _, foundationalIssue := range t.depIssues[:2] { + if issue.Title == foundationalIssue.Title { + expect.Equal(issue.Priority, 3, + fmt.Sprintf("%s priority", issue.Title)) + expect.Equal(issue.Assignee, "Me", + fmt.Sprintf("%s assignee", issue.Title)) + expect.Equal(issue.Status, models.StatusInProgress, + fmt.Sprintf("%s status", issue.Title)) + } + } + + } + + if !expect.Valid() { + return expect.ValidationFeedback + } + + expect.Equal(taskIssue.Status, models.StatusClosed, + fmt.Sprintf("%s", taskIssue.Title)) + return expect.Complete() } diff --git a/cmd/pm/tasks/gitTask.go b/cmd/pm/tasks/gitTask.go index 6520339..3ff56f5 100644 --- a/cmd/pm/tasks/gitTask.go +++ b/cmd/pm/tasks/gitTask.go @@ -4,6 +4,7 @@ import ( "context" "errors" "os" + "strings" "github.com/LazyBachelor/LazyPM/internal/models" "github.com/LazyBachelor/LazyPM/internal/utils/check" @@ -13,16 +14,21 @@ import ( const gitTaskDescription = `You are tasked with performing a Git operation. -This task will test your ability to use Git effectively within a project management workflow. +This task will test your ability to use Git effectively within a project management workflow. Your goal is to modify a file in a Git repository and commit the change. Your task: -1. Initialize or open a Git repository -2. Review the current repository status -3. Make appropriate changes to complete the task -4. Commit your changes with a meaningful message -5. Update the task status to reflect completion +1. Set the Issue status to "In Progress" when you are ready to start. +2. A folder called "task" is created in the project directory when you start this task. Open it. +3. Inside the folder you will find README.md. Edit this file and add something to it (e.g. your name, a short note, or a new line). The file must be different from its original content. +4. Commit your change: + - Open a terminal and change into the task folder. + - Run "git add ." to stage the changes. + - Run "git commit -m "updated codebase"" to commit. +5. Set the Issue status to "Closed" when done. -The repository has been initialized in ./task/.git/ for you to work with.` +The repository is in the task folder (./task).` + +const gitTaskReadmeContent = "This is a Git task. Add your name or a short note below this line to complete it, then commit your change." type GitTask struct { app *App @@ -31,6 +37,8 @@ type GitTask struct { setupIssue *Issue } +var gitTaskInProgress = false + func NewGitTask(app *App) *GitTask { return &GitTask{app: app, done: false} } @@ -39,8 +47,8 @@ func (t *GitTask) Config() Config { return BaseConfig().WithStatisticsStoragePath("./.pm/git-task-stats.json") } -func (t *GitTask) Details() TaskDetails { - return BaseDetails().WithTitle("Git Task").WithDescription(gitTaskDescription) +func (t *GitTask) Details(interfaceType InterfaceType) TaskDetails { + return BaseDetails(interfaceType).WithTitle("Git Task").WithDescription(gitTaskDescription) } func (t *GitTask) Questions(interfaceType InterfaceType) Questions { @@ -63,18 +71,21 @@ func (t *GitTask) QuestionnaireKeys(interfaceType InterfaceType) []string { } func (t *GitTask) Setup(ctx context.Context) error { + gitTaskInProgress = false if err := ClearIssues(t.app); err != nil { return err } + _ = os.RemoveAll("./task") + var err error t.repo, err = t.initRepo() if err != nil { return err } - os.WriteFile("./task/README.md", - []byte("This is a Git task. Please perform a Git operation here."), 0o644) + _ = os.WriteFile("./task/README.md", []byte(gitTaskReadmeContent), 0o644) + _ = os.WriteFile("./task/.gitattributes", []byte("* text=auto\n"), 0o644) t.setupIssue = NewIssueBuilder(). WithTitle("Git Task Setup Issue"). @@ -92,14 +103,87 @@ func (t *GitTask) Setup(ctx context.Context) error { func (t *GitTask) Validate(ctx context.Context) ValidationFeedback { expect := check.NewExpector() + issues, err := FetchIssues(ctx, t.app, t.setupIssue) + if err != nil { + return expect.ValidationFeedback + } + + + _ = issues + + issue := t.setupIssue + + + if issue.Status == models.StatusInProgress || gitTaskInProgress { + gitTaskInProgress = true + } else { + expect.Fail("The issue should be marked as In Progress while working on the Git task.") + return expect.ValidationFeedback + } + + // Ensure the repository is available. + if t.repo == nil { + t.repo, err = t.initRepo() + if err != nil { + expect.Fail("Failed to open the Git repository: " + err.Error()) + return expect.ValidationFeedback + } + } + + + headRef, err := t.repo.Head() + if err != nil { + expect.Fail("No commits found in the Git repository. Please commit your changes.") + return expect.ValidationFeedback + } + + commit, err := t.repo.CommitObject(headRef.Hash()) + if err != nil { + expect.Fail("Failed to read the latest commit: " + err.Error()) + return expect.ValidationFeedback + } + + expect.NotEmptyAndEqual(strings.TrimSpace(commit.Message), "updated codebase", "Git commit message") + + + tree, err := commit.Tree() + if err != nil { + expect.Fail("Failed to read commit tree: " + err.Error()) + return expect.ValidationFeedback + } + + readmeFile, err := tree.File("README.md") + if err != nil { + expect.Fail("README.md should be part of the committed changes.") + return expect.ValidationFeedback + } + + readmeContent, err := readmeFile.Contents() + if err != nil { + expect.Fail("Failed to read README.md from the commit: " + err.Error()) + return expect.ValidationFeedback + } + + expect.Assert(readmeContent != gitTaskReadmeContent, + "You should modify README.md content before committing (make appropriate changes to complete the task).") + + if wt, err := t.repo.Worktree(); err == nil { + if status, err := wt.Status(); err == nil { + expect.Assert(status.IsClean(), "The working tree should be clean after committing (no unstaged changes).") + } + } + + expect.Assert(gitTaskInProgress && issue.Status == models.StatusClosed, + "The issue should be marked as Closed after completing the Git task.") + return expect.Complete() } func (t *GitTask) initRepo() (*git.Repository, error) { - repo, err := git.PlainInit("./task/.git/", true) + repo, err := git.PlainInit("./task", false) if err != nil { if errors.Is(err, git.ErrTargetDirNotEmpty) { - repo, err = git.PlainOpen("./task/.git/") + repo, err = git.PlainOpen("./task") if err != nil { return nil, err } diff --git a/cmd/pm/tasks/issueReviewCleanup.go b/cmd/pm/tasks/issueReviewCleanup.go index 984662b..79aa453 100644 --- a/cmd/pm/tasks/issueReviewCleanup.go +++ b/cmd/pm/tasks/issueReviewCleanup.go @@ -31,8 +31,8 @@ func (t *IssueReviewCleanupTask) Config() Config { return BaseConfig().WithStatisticsStoragePath("./.pm/issue-review-cleanup-stats.json") } -func (t *IssueReviewCleanupTask) Details() TaskDetails { - return BaseDetails(). +func (t *IssueReviewCleanupTask) Details(interfaceType InterfaceType) TaskDetails { + return BaseDetails(interfaceType). WithTitle("Issue Review and Cleanup Task"). WithDescription(issueReviewCleanupDescription). WithTimeToComplete("8m"). diff --git a/cmd/pm/tasks/issueTriage.go b/cmd/pm/tasks/issueTriage.go deleted file mode 100644 index a2c64ce..0000000 --- a/cmd/pm/tasks/issueTriage.go +++ /dev/null @@ -1,119 +0,0 @@ -package tasks - -import ( - "context" - - "github.com/LazyBachelor/LazyPM/internal/models" - "github.com/LazyBachelor/LazyPM/internal/utils/check" - "github.com/charmbracelet/huh" -) - -const issueTriageDescription = `You are tasked with triaging incoming issues. - -The support team has submitted several bug reports and feature requests that need to be reviewed and prioritized. Your job is to: - -1. Review each incoming issue -2. Assign appropriate priority -3. Set the correct status -4. Identify if it's a bug, feature, or task -5. Leave comments explaining your decisions - -Make decisions quickly but thoughtfully. Not everything is high priority!` - -type IssueTriageTask struct { - done bool - app *App - setupIssue *models.Issue -} - -func NewIssueTriageTask(app *App) *IssueTriageTask { - return &IssueTriageTask{app: app, done: false} -} - -func (t *IssueTriageTask) Config() Config { - return BaseConfig().WithStatisticsStoragePath("./.pm/triage-task-stats.json") -} - -func (t *IssueTriageTask) Details() TaskDetails { - return BaseDetails(). - WithTitle("Issue Triage Task"). - WithDescription(issueTriageDescription). - WithTimeToComplete("12m"). - WithDifficulty("Medium") -} - -func (t *IssueTriageTask) Questions(interfaceType InterfaceType) Questions { - return BaseQuestions(interfaceType).With( - huh.NewGroup( - huh.NewSelect[int](). - Title("How many Critical priority issues did you identify?"). - Options( - huh.NewOption("0", 0), - huh.NewOption("1", 1), - huh.NewOption("2", 2), - huh.NewOption("3+", 3), - ), - ), - ) -} - -func (t *IssueTriageTask) Setup(ctx context.Context) error { - if err := ClearIssues(t.app); err != nil { - return err - } - - triageIssues := []*models.Issue{ - models.NewIssueBuilder(). - WithTitle("App crashes on login"). - WithDescription("Users report the app crashes immediately after entering credentials. Affects all users on Android 12. Needs urgent attention."). - WithPriority(1). - WithStatus(models.StatusOpen). - WithIssueType(models.TypeTask). - Build(), - models.NewIssueBuilder(). - WithTitle("Dark mode support"). - WithDescription("Users requesting dark mode theme for better night time usage. Nice to have feature."). - WithPriority(3). - WithStatus(models.StatusOpen). - WithIssueType(models.TypeTask). - Build(), - models.NewIssueBuilder(). - WithTitle("Database timeout errors"). - WithDescription("Intermittent timeouts when querying large datasets. Needs investigation."). - WithPriority(1). - WithStatus(models.StatusOpen). - WithIssueType(models.TypeTask). - Build(), - models.NewIssueBuilder(). - WithTitle("Add export to CSV feature"). - WithDescription("Sales team needs ability to export reports to CSV format."). - WithPriority(2). - WithStatus(models.StatusOpen). - WithIssueType(models.TypeTask). - Build(), - models.NewIssueBuilder(). - WithTitle("Security: Password reset vulnerability"). - WithDescription("Reported by security audit - password reset tokens don't expire. Critical security issue."). - WithPriority(0). - WithStatus(models.StatusOpen). - WithIssueType(models.TypeTask). - Build(), - } - - if err := t.app.Issues.CreateIssues(ctx, triageIssues, ""); err != nil { - return err - } - - t.setupIssue = models.NewBaseIssue(). - WithTitle("Issue Triage Queue"). - WithDescription(issueTriageDescription). - Build() - - return t.app.Issues.CreateIssue(ctx, t.setupIssue, "") -} - -func (t *IssueTriageTask) Validate(ctx context.Context) ValidationFeedback { - expect := check.NewExpector() - - return expect.Complete() -} diff --git a/cmd/pm/tasks/milestoneTracking.go b/cmd/pm/tasks/milestoneTracking.go deleted file mode 100644 index f6ce7fd..0000000 --- a/cmd/pm/tasks/milestoneTracking.go +++ /dev/null @@ -1,119 +0,0 @@ -package tasks - -import ( - "context" - - "github.com/LazyBachelor/LazyPM/internal/models" - "github.com/LazyBachelor/LazyPM/internal/utils/check" - "github.com/charmbracelet/huh" -) - -const milestoneTrackingDescription = `You are tasked with managing a project milestone. - -The "Q1 Release" milestone is approaching and you need to ensure all issues are on track. -Review the milestone issues and: - -1. Identify issues at risk of missing the deadline -2. Update issue statuses to reflect current progress -3. Flag any blockers or dependencies causing delays -4. Close completed issues -5. Provide status updates for stakeholder visibility - -The milestone deadline is 2 weeks away. Some issues have dependencies that need to be resolved first.` - -type MilestoneTrackingTask struct { - done bool - app *App - setupIssue *Issue -} - -func NewMilestoneTrackingTask(app *App) *MilestoneTrackingTask { - return &MilestoneTrackingTask{app: app, done: false} -} - -func (t *MilestoneTrackingTask) Config() Config { - return BaseConfig().WithStatisticsStoragePath("./.pm/milestone-task-stats.json") -} - -func (t *MilestoneTrackingTask) Details() TaskDetails { - return BaseDetails(). - WithTitle("Milestone Tracking Task"). - WithDescription(milestoneTrackingDescription). - WithTimeToComplete("10m"). - WithDifficulty("Easy") -} - -func (t *MilestoneTrackingTask) Questions(interfaceType InterfaceType) Questions { - return BaseQuestions(interfaceType).With( - huh.NewGroup( - huh.NewSelect[int](). - Title("How many issues did you identify as at-risk?"). - Options( - huh.NewOption("0", 0), - huh.NewOption("1-2", 1), - huh.NewOption("3+", 2), - ), - ), - ) -} - -func (t *MilestoneTrackingTask) Setup(ctx context.Context) error { - if err := ClearIssues(t.app); err != nil { - return err - } - - milestoneIssues := []*models.Issue{ - NewIssueBuilder(). - WithTitle("Core API endpoints"). - WithDescription("Implement REST API for core functionality. COMPLETED"). - WithPriority(1). - WithStatus(models.StatusClosed). - WithIssueType(models.TypeTask). - Build(), - NewIssueBuilder(). - WithTitle("Frontend dashboard"). - WithDescription("Create main dashboard UI. In progress - 80% complete"). - WithPriority(1). - WithStatus(models.StatusInProgress). - WithIssueType(models.TypeTask). - Build(), - NewIssueBuilder(). - WithTitle("User management module"). - WithDescription("Depends on Core API. Add user CRUD operations. Currently blocked"). - WithPriority(2). - WithStatus(models.StatusBlocked). - WithIssueType(models.TypeTask). - Build(), - NewIssueBuilder(). - WithTitle("Analytics reporting"). - WithDescription("Generate usage analytics reports. Not started yet"). - WithPriority(3). - WithStatus(models.StatusOpen). - WithIssueType(models.TypeTask). - Build(), - NewIssueBuilder(). - WithTitle("Email notifications"). - WithDescription("Setup email service for alerts. 50% complete"). - WithPriority(2). - WithStatus(models.StatusInProgress). - WithIssueType(models.TypeTask). - Build(), - } - - if err := t.app.Issues.CreateIssues(ctx, milestoneIssues, ""); err != nil { - return err - } - - t.setupIssue = NewIssueBuilder(). - WithTitle("Q1 Release Milestone Tracking"). - WithDescription(milestoneTrackingDescription). - Build() - - return t.app.Issues.CreateIssue(ctx, t.setupIssue, "") -} - -func (t *MilestoneTrackingTask) Validate(ctx context.Context) ValidationFeedback { - expect := check.NewExpector() - - return expect.Complete() -} diff --git a/cmd/pm/tasks/priorityManagement.go b/cmd/pm/tasks/priorityManagement.go index a5b2b5c..a541ccc 100644 --- a/cmd/pm/tasks/priorityManagement.go +++ b/cmd/pm/tasks/priorityManagement.go @@ -2,6 +2,7 @@ package tasks import ( "context" + "fmt" "github.com/LazyBachelor/LazyPM/internal/models" "github.com/LazyBachelor/LazyPM/internal/utils/check" @@ -10,21 +11,24 @@ import ( const priorityManagementDescription = `You are tasked with managing issue priorities. -A critical production issue has been reported. You need to rebalance the current sprint priorities: +A critical production issue has been reported. -1. Review all current issues and their priorities -2. Identify the most urgent production issue -3. Reprioritize existing work to accommodate the urgent fix -4. Defer lower priority items if necessary -5. Update the team on priority changes via comments -6. Ensure the critical path is clear for the urgent fix +The database is not working properly and users are not able to connect and access their data. -The production database is experiencing intermittent connection failures affecting all users.` +You need to rebalance the current sprint priorities: + +1. Assign the task Issue you are currently reading to yourself as "Me" and set status to "In Progress". +2. A new issue has appeared in the list that needs urgent attention. Change the database related issue's priority to 4 (critical). +3. Set the priority of the feature and chore issues in the list to 1 (low). +4. Change this issue status to "Closed". +` type PriorityManagementTask struct { - done bool - app *App - setupIssue *Issue + done bool + app *App + setupIssue *Issue + priorityIssues []*models.Issue + isInProgress bool } func NewPriorityManagementTask(app *App) *PriorityManagementTask { @@ -35,8 +39,8 @@ func (t *PriorityManagementTask) Config() Config { return BaseConfig().WithStatisticsStoragePath("./.pm/priority-task-stats.json") } -func (t *PriorityManagementTask) Details() TaskDetails { - return BaseDetails(). +func (t *PriorityManagementTask) Details(interfaceType InterfaceType) TaskDetails { + return BaseDetails(interfaceType). WithTitle("Priority Management Task"). WithDescription(priorityManagementDescription). WithTimeToComplete("8m"). @@ -63,7 +67,7 @@ func (t *PriorityManagementTask) Setup(ctx context.Context) error { return err } - priorityIssues := []*models.Issue{ + t.priorityIssues = []*models.Issue{ NewIssueBuilder(). WithTitle("Database connection failures"). WithDescription("PRODUCTION CRITICAL: Intermittent DB connection failures affecting all users. Needs immediate attention."). @@ -74,34 +78,34 @@ func (t *PriorityManagementTask) Setup(ctx context.Context) error { NewIssueBuilder(). WithTitle("UI theme updates"). WithDescription("Update color scheme per new brand guidelines. Currently in progress but can wait."). - WithPriority(1). + WithPriority(2). WithStatus(models.StatusInProgress). - WithIssueType(models.TypeTask). + WithIssueType(models.TypeFeature). Build(), NewIssueBuilder(). WithTitle("Feature: Dark mode"). WithDescription("Add dark mode toggle to settings. Nice to have, can be deferred."). WithPriority(2). WithStatus(models.StatusOpen). - WithIssueType(models.TypeTask). + WithIssueType(models.TypeFeature). Build(), NewIssueBuilder(). WithTitle("API rate limiting"). WithDescription("Add rate limiting to public API endpoints. Security enhancement."). WithPriority(2). WithStatus(models.StatusInProgress). - WithIssueType(models.TypeTask). + WithIssueType(models.TypeFeature). Build(), NewIssueBuilder(). WithTitle("Documentation updates"). WithDescription("Update API documentation for v2 endpoints. Can be deferred."). WithPriority(3). WithStatus(models.StatusOpen). - WithIssueType(models.TypeTask). + WithIssueType(models.TypeChore). Build(), } - if err := t.app.Issues.CreateIssues(ctx, priorityIssues, ""); err != nil { + if err := t.app.Issues.CreateIssues(ctx, t.priorityIssues, ""); err != nil { return err } @@ -116,5 +120,35 @@ func (t *PriorityManagementTask) Setup(ctx context.Context) error { func (t *PriorityManagementTask) Validate(ctx context.Context) ValidationFeedback { expect := check.NewExpector() + issues, err := FetchIssues(ctx, t.app, t.setupIssue) + if err != nil { + return expect.Fatal("Failed to fetch issues for validation") + } + + expect.NotEmptyAndEqual(t.setupIssue.Assignee, "Me", + fmt.Sprintf("%s assignee", t.setupIssue.Title)) + + if t.setupIssue.Status != models.StatusClosed { + expect.Equal(t.setupIssue.Status, models.StatusInProgress, + fmt.Sprintf("%s status", t.setupIssue.Title)) + } + + if !expect.Valid() { + return expect.ValidationFeedback + } + + for _, issue := range issues { + if issue.Title == t.priorityIssues[0].Title { + expect.Equal(issue.Priority, 4, + fmt.Sprintf("Priority of issue %s", issue.Title)) + } else { + expect.Equal(issue.Priority, 1, + fmt.Sprintf("Priority of issue %s", issue.Title)) + } + } + + expect.Equal(t.setupIssue.Status, models.StatusClosed, + fmt.Sprintf("%s status", t.setupIssue.Title)) + return expect.Complete() } diff --git a/cmd/pm/tasks/reportGeneration.go b/cmd/pm/tasks/reportGeneration.go deleted file mode 100644 index 2b3e735..0000000 --- a/cmd/pm/tasks/reportGeneration.go +++ /dev/null @@ -1,119 +0,0 @@ -package tasks - -import ( - "context" - - "github.com/LazyBachelor/LazyPM/internal/models" - "github.com/LazyBachelor/LazyPM/internal/utils/check" - "github.com/charmbracelet/huh" -) - -const reportGenerationDescription = `You are tasked with generating a project status report. - -The stakeholders need a weekly status update. Review the current project state and: - -1. Identify completed issues since last report -2. Count issues in progress and their status -3. Note any blocked items and blockers -4. Calculate velocity -5. Flag any risks or concerns -6. Update the project status - -Add summary comments to at least 3 key issues that stakeholders should know about.` - -type ReportGenerationTask struct { - done bool - app *App - setupIssue *Issue -} - -func NewReportGenerationTask(app *App) *ReportGenerationTask { - return &ReportGenerationTask{app: app, done: false} -} - -func (t *ReportGenerationTask) Config() Config { - return BaseConfig().WithStatisticsStoragePath("./.pm/report-task-stats.json") -} - -func (t *ReportGenerationTask) Details() TaskDetails { - return BaseDetails(). - WithTitle("Status Report Generation"). - WithDescription(reportGenerationDescription). - WithTimeToComplete("10m"). - WithDifficulty("Easy") -} - -func (t *ReportGenerationTask) Questions(interfaceType InterfaceType) Questions { - return BaseQuestions(interfaceType).With( - huh.NewGroup( - huh.NewSelect[int](). - Title("What is the overall project status?"). - Options( - huh.NewOption("On Track", 1), - huh.NewOption("At Risk", 2), - huh.NewOption("Off Track", 3), - ), - ), - ) -} - -func (t *ReportGenerationTask) Setup(ctx context.Context) error { - if err := ClearIssues(t.app); err != nil { - return err - } - - reportIssues := []*models.Issue{ - NewIssueBuilder(). - WithTitle("User login feature"). - WithDescription("Allow users to login with email/password."). - WithPriority(1). - WithStatus(models.StatusClosed). - WithIssueType(models.TypeTask). - Build(), - NewIssueBuilder(). - WithTitle("Password reset"). - WithDescription("Email-based password reset flow. In Progress"). - WithPriority(2). - WithStatus(models.StatusInProgress). - WithIssueType(models.TypeTask). - Build(), - NewIssueBuilder(). - WithTitle("Database optimization"). - WithDescription("Optimize slow queries identified in profiling."). - WithPriority(1). - WithStatus(models.StatusBlocked). - WithIssueType(models.TypeTask). - Build(), - NewIssueBuilder(). - WithTitle("Mobile responsive design"). - WithDescription("Make UI work on mobile devices."). - WithPriority(2). - WithStatus(models.StatusClosed). - WithIssueType(models.TypeTask). - Build(), - NewIssueBuilder(). - WithTitle("Third-party API integration"). - WithDescription("Waiting for vendor API documentation."). - WithPriority(1). - WithStatus(models.StatusBlocked). - WithIssueType(models.TypeTask). - Build(), - } - - if err := t.app.Issues.CreateIssues(ctx, reportIssues, ""); err != nil { - return err - } - - t.setupIssue = NewIssueBuilder(). - WithTitle("Weekly Status Report"). - WithDescription(reportGenerationDescription). - Build() - - return t.app.Issues.CreateIssue(ctx, t.setupIssue, "") -} - -func (t *ReportGenerationTask) Validate(ctx context.Context) ValidationFeedback { - expect := check.NewExpector() - - return expect.Complete() -} diff --git a/cmd/pm/tasks/sprintPlanning.go b/cmd/pm/tasks/sprintPlanning.go index 572fa41..a7ecf66 100644 --- a/cmd/pm/tasks/sprintPlanning.go +++ b/cmd/pm/tasks/sprintPlanning.go @@ -36,8 +36,8 @@ func (t *SprintPlanningTask) Config() Config { return BaseConfig().WithStatisticsStoragePath("./.pm/sprint-planning-stats.json") } -func (t *SprintPlanningTask) Details() TaskDetails { - return BaseDetails(). +func (t *SprintPlanningTask) Details(interfaceType InterfaceType) TaskDetails { + return BaseDetails(interfaceType). WithTitle("Sprint Planning Task"). WithDescription(sprintPlanningDescription). WithTimeToComplete("15m"). @@ -48,6 +48,7 @@ func (t *SprintPlanningTask) Questions(interfaceType InterfaceType) Questions { return BaseQuestions(interfaceType).With( huh.NewGroup( huh.NewSelect[int](). + Key("sprint-planning-selected-issues"). Title("How many issues did you select for the sprint?"). Options( huh.NewOption("2-3 issues", 1), @@ -58,6 +59,10 @@ func (t *SprintPlanningTask) Questions(interfaceType InterfaceType) Questions { ) } +func (t *SprintPlanningTask) QuestionnaireKeys(_ InterfaceType) []string { + return []string{"task_completed", "task_difficulty", "sprint-planning-selected-issues"} +} + func (t *SprintPlanningTask) Setup(ctx context.Context) error { if err := ClearIssues(t.app); err != nil { return err @@ -116,6 +121,50 @@ func (t *SprintPlanningTask) Setup(ctx context.Context) error { func (t *SprintPlanningTask) Validate(ctx context.Context) ValidationFeedback { expect := check.NewExpector() - return expect.Complete() + issues, err := FetchIssues(ctx, t.app, t.setupIssue) + if err != nil { + return expect.ValidationFeedback + } + if len(issues) == 0 { + expect.Fail("No backlog issues found to plan a sprint with.") + return expect.ValidationFeedback + } + + // Sort by priority ascending (0 is highest priority). + sorted := make([]*models.Issue, len(issues)) + copy(sorted, issues) + for i := 0; i < len(sorted); i++ { + for j := i + 1; j < len(sorted); j++ { + if sorted[j].Priority < sorted[i].Priority { + sorted[i], sorted[j] = sorted[j], sorted[i] + } + } + } + + topN := 5 + if len(sorted) < topN { + topN = len(sorted) + } + top := sorted[:topN] + + var plannedCount int + var readyToSprintCount int + for _, issue := range top { + if issue.Status == models.StatusReadyToSprint || + issue.Status == models.StatusInProgress || + issue.Status == models.StatusClosed { + plannedCount++ + } + if issue.Status == models.StatusReadyToSprint { + readyToSprintCount++ + } + } + + expect.Assert(plannedCount >= 3, + "Expected at least 3 of the 5 highest-priority issues to be moved into 'ready_to_sprint', 'in_progress', or 'closed' for the sprint.") + expect.Assert(readyToSprintCount >= 1, + "Expected at least one of the highest-priority issues to be marked as 'ready_to_sprint' to indicate it is planned for the sprint.") + + return expect.Complete() } diff --git a/cmd/pm/tasks/stakeholderUpdate.go b/cmd/pm/tasks/stakeholderUpdate.go deleted file mode 100644 index de34665..0000000 --- a/cmd/pm/tasks/stakeholderUpdate.go +++ /dev/null @@ -1,114 +0,0 @@ -package tasks - -import ( - "context" - - "github.com/LazyBachelor/LazyPM/internal/models" - "github.com/LazyBachelor/LazyPM/internal/utils/check" - "github.com/charmbracelet/huh" -) - -const stakeholderUpdateDescription = `You are tasked with preparing stakeholder updates. - -A key stakeholder has requested an update on the progress of their requested features. -You need to: - -1. Identify issues related to the stakeholder's requests (marked with "stakeholder" in description) -2. Review the current status of each issue -3. Provide clear, non-technical status updates via comments -4. Highlight any blockers or delays -5. Set realistic expectations for delivery -6. Close completed items with completion notes - -The stakeholder is interested in the Dashboard Enhancement and Export Features specifically.` - -type StakeholderUpdateTask struct { - done bool - app *App - setupIssue *Issue -} - -func NewStakeholderUpdateTask(app *App) *StakeholderUpdateTask { - return &StakeholderUpdateTask{app: app, done: false} -} - -func (t *StakeholderUpdateTask) Config() Config { - return BaseConfig().WithStatisticsStoragePath("./.pm/stakeholder-task-stats.json") -} - -func (t *StakeholderUpdateTask) Details() TaskDetails { - return BaseDetails(). - WithTitle("Stakeholder Update Task"). - WithDescription(stakeholderUpdateDescription). - WithTimeToComplete("10m"). - WithDifficulty("Easy") -} - -func (t *StakeholderUpdateTask) Questions(interfaceType InterfaceType) Questions { - return BaseQuestions(interfaceType).With( - huh.NewGroup( - huh.NewSelect[int](). - Title("How satisfied will the stakeholder be with this update?"). - Options( - huh.NewOption("Very satisfied", 1), - huh.NewOption("Satisfied", 2), - huh.NewOption("Neutral", 3), - huh.NewOption("Concerned", 4), - ), - ), - ) -} - -func (t *StakeholderUpdateTask) Setup(ctx context.Context) error { - if err := ClearIssues(t.app); err != nil { - return err - } - - stakeholderIssues := []*models.Issue{ - NewIssueBuilder(). - WithTitle("Dashboard Enhancement - Charts"). - WithDescription("Add interactive charts to the main dashboard (stakeholder request). COMPLETED"). - WithPriority(1). - WithStatus(models.StatusClosed). - WithIssueType(models.TypeTask). - Build(), - NewIssueBuilder(). - WithTitle("Dashboard Enhancement - Filters"). - WithDescription("Add date range filters to dashboard (stakeholder request). In Progress"). - WithPriority(2). - WithStatus(models.StatusInProgress). - WithIssueType(models.TypeTask). - Build(), - NewIssueBuilder(). - WithTitle("Export to PDF"). - WithDescription("Allow exporting reports to PDF format (stakeholder request). In Progress"). - WithPriority(1). - WithStatus(models.StatusInProgress). - WithIssueType(models.TypeTask). - Build(), - NewIssueBuilder(). - WithTitle("Export to Excel"). - WithDescription("Allow exporting data to Excel format (stakeholder request). BLOCKED - waiting for library approval"). - WithPriority(2). - WithStatus(models.StatusBlocked). - WithIssueType(models.TypeTask). - Build(), - } - - if err := t.app.Issues.CreateIssues(ctx, stakeholderIssues, ""); err != nil { - return err - } - - t.setupIssue = NewIssueBuilder(). - WithTitle("Stakeholder Update Preparation"). - WithDescription(stakeholderUpdateDescription). - Build() - - return t.app.Issues.CreateIssue(ctx, t.setupIssue, "") -} - -func (t *StakeholderUpdateTask) Validate(ctx context.Context) ValidationFeedback { - expect := check.NewExpector() - - return expect.Complete() -} diff --git a/cmd/pm/tasks/teamCapacity.go b/cmd/pm/tasks/teamCapacity.go deleted file mode 100644 index 3f3fb03..0000000 --- a/cmd/pm/tasks/teamCapacity.go +++ /dev/null @@ -1,126 +0,0 @@ -package tasks - -import ( - "context" - - "github.com/LazyBachelor/LazyPM/internal/models" - "github.com/LazyBachelor/LazyPM/internal/utils/check" - "github.com/charmbracelet/huh" -) - -const teamCapacityDescription = `You are tasked with managing team capacity. - -You have a team of 4 developers with varying skill and availability. -Review the upcoming sprint workload and: - -1. Review assigned issues and their priorities -2. Identify overloaded team members based on issue assignments -3. Rebalance workload to match capacity -4. Consider vacations and time off mentioned in issue descriptions -5. Ensure critical tasks have coverage - -Team member Alice is on vacation next week (mentioned in her issues). Bob can only work 50% time due to other commitments.` - -type TeamCapacityTask struct { - done bool - app *App - setupIssue *Issue -} - -func NewTeamCapacityTask(app *App) *TeamCapacityTask { - return &TeamCapacityTask{app: app, done: false} -} - -func (t *TeamCapacityTask) Config() Config { - return BaseConfig().WithStatisticsStoragePath("./.pm/capacity-task-stats.json") -} - -func (t *TeamCapacityTask) Details() TaskDetails { - return BaseDetails(). - WithTitle("Team Capacity Management"). - WithDescription(teamCapacityDescription). - WithTimeToComplete("12m"). - WithDifficulty("Medium") -} - -func (t *TeamCapacityTask) Questions(interfaceType InterfaceType) Questions { - return BaseQuestions(interfaceType).With( - huh.NewGroup( - huh.NewSelect[int](). - Title("How many issues did you reassign or update?"). - Options( - huh.NewOption("0", 0), - huh.NewOption("1-2", 1), - huh.NewOption("3+", 2), - ), - ), - ) -} - -func (t *TeamCapacityTask) Setup(ctx context.Context) error { - if err := ClearIssues(t.app); err != nil { - return err - } - - capacityIssues := []*models.Issue{ - NewIssueBuilder(). - WithTitle("Authentication module"). - WithDescription("Implement OAuth2 flow. Assigned to: Alice (on vacation next week)"). - WithPriority(1). - WithStatus(models.StatusInProgress). - WithIssueType(models.TypeTask). - Build(), - NewIssueBuilder(). - WithTitle("Payment integration"). - WithDescription("Integrate Stripe API. Assigned to: Alice (on vacation next week)"). - WithPriority(1). - WithStatus(models.StatusOpen). - WithIssueType(models.TypeTask). - Build(), - NewIssueBuilder(). - WithTitle("Dashboard widgets"). - WithDescription("Create reusable widget components. Assigned to: Bob (50% capacity)"). - WithPriority(2). - WithStatus(models.StatusInProgress). - WithIssueType(models.TypeTask). - Build(), - NewIssueBuilder(). - WithTitle("API rate limiting"). - WithDescription("Add rate limiting middleware. Assigned to: Bob (50% capacity)"). - WithPriority(2). - WithStatus(models.StatusOpen). - WithIssueType(models.TypeTask). - Build(), - NewIssueBuilder(). - WithTitle("Data migration"). - WithDescription("Migrate legacy data to new schema. Assigned to: Charlie (full capacity)"). - WithPriority(3). - WithStatus(models.StatusOpen). - WithIssueType(models.TypeTask). - Build(), - NewIssueBuilder(). - WithTitle("Bug fixes batch"). - WithDescription("Fix reported bugs from QA. Assigned to: Diana (full capacity)"). - WithPriority(1). - WithStatus(models.StatusOpen). - WithIssueType(models.TypeTask). - Build(), - } - - if err := t.app.Issues.CreateIssues(ctx, capacityIssues, ""); err != nil { - return err - } - - t.setupIssue = NewIssueBuilder(). - WithTitle("Team Capacity Planning"). - WithDescription(teamCapacityDescription). - Build() - - return t.app.Issues.CreateIssue(ctx, t.setupIssue, "") -} - -func (t *TeamCapacityTask) Validate(ctx context.Context) ValidationFeedback { - expect := check.NewExpector() - - return expect.Complete() -} diff --git a/go.mod b/go.mod index ff1e7b2..c41c87b 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.25.6 require ( charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251106193318-19329a3e8410 github.com/go-git/go-git/v6 v6.0.0-20260222090600-424e9964d3a3 + github.com/joho/godotenv v1.5.1 github.com/muesli/reflow v0.3.0 github.com/steveyegge/beads v0.49.6 go.mongodb.org/mongo-driver v1.17.9 @@ -25,7 +26,7 @@ require ( // Web dependencies require ( github.com/NYTimes/gziphandler v1.1.1 - github.com/a-h/templ v0.3.977 + github.com/a-h/templ v0.3.1001 github.com/donseba/go-htmx v1.12.1 github.com/go-chi/chi/v5 v5.2.5 github.com/go-playground/form/v4 v4.3.0 @@ -69,7 +70,7 @@ require ( github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/golang/snappy v0.0.4 // indirect - github.com/haatos/goshipit v0.0.0-20260206030541-056850f43320 // indirect + github.com/haatos/goshipit v0.0.0-20260305043009-36e5c9a2e5c6 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/kevinburke/ssh_config v1.5.0 // indirect github.com/klauspost/compress v1.18.0 // indirect diff --git a/go.sum b/go.sum index 9e37a12..3ce74b3 100644 --- a/go.sum +++ b/go.sum @@ -10,8 +10,8 @@ github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBi github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE= github.com/a-h/parse v0.0.0-20250122154542-74294addb73e h1:HjVbSQHy+dnlS6C3XajZ69NYAb5jbGNfHanvm1+iYlo= github.com/a-h/parse v0.0.0-20250122154542-74294addb73e/go.mod h1:3mnrkvGpurZ4ZrTDbYU84xhwXW2TjTKShSwjRi2ihfQ= -github.com/a-h/templ v0.3.977 h1:kiKAPXTZE2Iaf8JbtM21r54A8bCNsncrfnokZZSrSDg= -github.com/a-h/templ v0.3.977/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo= +github.com/a-h/templ v0.3.1001 h1:yHDTgexACdJttyiyamcTHXr2QkIeVF1MukLy44EAhMY= +github.com/a-h/templ v0.3.1001/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo= github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= @@ -128,10 +128,12 @@ github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/haatos/goshipit v0.0.0-20260206030541-056850f43320 h1:sb7SfxZfN+U9OHC61tcS98Ge0zY9uEkW5CP6KB4YVHg= -github.com/haatos/goshipit v0.0.0-20260206030541-056850f43320/go.mod h1:LFP8N8y5ORkifb+LZuOVNZYlJuV3WqdXCjxX5pGUaNI= +github.com/haatos/goshipit v0.0.0-20260305043009-36e5c9a2e5c6 h1:Yakj3J1ZxYCpFtBHUVkSBNA80LK/8bNrl/LA8fSvvFw= +github.com/haatos/goshipit v0.0.0-20260305043009-36e5c9a2e5c6/go.mod h1:2A31H3xgQHTgNp8OlX7aliXaY3QFwtI7XHuaP8G1ZSw= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/kevinburke/ssh_config v1.5.0 h1:3cPZmE54xb5j3G5xQCjSvokqNwU2uW+3ry1+PRLSPpA= github.com/kevinburke/ssh_config v1.5.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= diff --git a/internal/app/app.go b/internal/app/app.go index b44afa2..1ee6aa2 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -7,6 +7,7 @@ import ( "github.com/LazyBachelor/LazyPM/internal/models" "github.com/LazyBachelor/LazyPM/internal/storage" "github.com/steveyegge/beads" + "go.mongodb.org/mongo-driver/bson/primitive" ) type App = models.App @@ -22,13 +23,13 @@ func New(ctx context.Context, config Config, opts ...Option) (*App, func(), erro } if !config.AutoInit { - if err := b.initializer.Init(config.BeadsDBPath); err != nil { + if err := b.initializer.Init(config.AppDir + "db.db"); err != nil { return nil, nil, err } } if b.issueService == nil { - sqliteStore, err := beads.NewSQLiteStorage(b.ctx, config.BeadsDBPath) + sqliteStore, err := beads.NewSQLiteStorage(b.ctx, config.AppDir+"db.db") if err != nil { return nil, nil, err } @@ -42,7 +43,7 @@ func New(ctx context.Context, config Config, opts ...Option) (*App, func(), erro if b.statsService == nil { statStore := storage.NewJsonStorage(config.StatisticsStoragePath, &models.Statistics{ - ID: 0, + ID: primitive.NewObjectID(), StartTime: time.Now(), }) diff --git a/internal/app/statistics.go b/internal/app/statistics.go index dadc342..abd12a6 100644 --- a/internal/app/statistics.go +++ b/internal/app/statistics.go @@ -9,6 +9,7 @@ import ( "github.com/LazyBachelor/LazyPM/internal/models" "github.com/LazyBachelor/LazyPM/internal/storage" + "go.mongodb.org/mongo-driver/bson/primitive" ) type StatisticsService struct { @@ -42,6 +43,16 @@ func (s *StatisticsService) GetStatistics() (models.Statistics, error) { return *s.storage.Data, nil } +func (s *StatisticsService) GetParticipantID() primitive.ObjectID { + s.mu.Lock() + defer s.mu.Unlock() + + if s.storage.Data == nil { + return primitive.NilObjectID + } + return s.storage.Data.ID +} + func (s *StatisticsService) RecordTaskRun(ctx context.Context, run models.TaskRunMetrics) error { _ = ctx @@ -63,8 +74,8 @@ func (s *StatisticsService) RecordTaskRun(ctx context.Context, run models.TaskRu } stats.EndTime = now - stats.Duration = stats.EndTime.Sub(stats.StartTime) - stats.InterfaceType = run.InterfaceType + stats.DurationMs = stats.EndTime.Sub(stats.StartTime).Milliseconds() + stats.LastInterfaceType = run.InterfaceType stats.TaskRuns++ stats.LastTaskName = run.TaskName @@ -120,3 +131,29 @@ func (s *StatisticsService) RecordTaskRun(ctx context.Context, run models.TaskRu return nil } + +func (s *StatisticsService) RecordIntroQuestionnaireAnswers(answers map[string]any) error { + s.mu.Lock() + defer s.mu.Unlock() + + if s.storage.Data == nil { + return fmt.Errorf("statistics data not initialized") + } + + s.storage.Data.IntroQuestionnaireAnswers = answers + + if err := s.storage.Save(); err != nil { + if s.logger != nil { + s.logger.Error("failed to save intro questionnaire answers", "error", err) + } + return err + } + + if s.logger != nil { + s.logger.Info("intro questionnaire answers saved", + "answers_count", len(answers), + ) + } + + return nil +} diff --git a/internal/commands/issues/close.go b/internal/commands/issues/close.go index 3c89b10..ce4bfcb 100644 --- a/internal/commands/issues/close.go +++ b/internal/commands/issues/close.go @@ -7,8 +7,6 @@ import ( "github.com/spf13/cobra" ) -// CloseCmd represents the close command, -// which allows users to close an existing issue by its ID. var CloseCmd = &cobra.Command{ Use: "close [id]", Short: "Close an existing issue", @@ -21,8 +19,6 @@ var CloseCmd = &cobra.Command{ ValidArgsFunction: completeIssues, } -// runCloseCmd executes the close command logic, -// which closes an issue by its ID after confirming with the user. func runCloseCmd(cmd *cobra.Command, args []string) error { closeID := args[0] @@ -42,14 +38,30 @@ func runCloseCmd(cmd *cobra.Command, args []string) error { return fmt.Errorf("issue with ID %s not found", closeID) } - // Ask for closing reason - if err = huh.NewInput().Value(&issue.CloseReason). - Title("Reason for closing the issue?").WithTheme(huh.ThemeBase()).Run(); err != nil { + closeReason := "" + if err = huh.NewSelect[string]().Value(&closeReason). + Title("Reason for closing the issue?"). + Options( + huh.NewOption("Done", "Done"), + huh.NewOption("Duplicate issue", "Duplicate issue"), + huh.NewOption("Won't fix", "Won't fix"), + huh.NewOption("Obsolete", "Obsolete"), + huh.NewOption("Other", "Other"), + ).WithTheme(huh.ThemeBase()).Run(); err != nil { return fmt.Errorf("error getting close reason: %w", err) } - // Close the issue. - err = app.Issues.CloseIssue(cmd.Context(), closeID, issue.CloseReason, "", "") + if closeReason == "Other" { + if err = huh.NewInput().Value(&closeReason). + Title("Enter closing reason:").WithTheme(huh.ThemeBase()).Run(); err != nil { + return fmt.Errorf("error getting close reason: %w", err) + } + if closeReason == "" { + return fmt.Errorf("closing reason cannot be empty when selecting 'Other'") + } + } + + err = app.Issues.CloseIssue(cmd.Context(), closeID, closeReason, "", "") if err != nil { return fmt.Errorf("error closing issue: %w", err) } diff --git a/internal/commands/issues/completion.go b/internal/commands/issues/completion.go index a363235..1ab4670 100644 --- a/internal/commands/issues/completion.go +++ b/internal/commands/issues/completion.go @@ -10,8 +10,8 @@ import ( // Variables for completion options and functions. var ( - typeOptions = []string{"bug", "feature", "task"} - statusOptions = []string{"open", "closed", "in_progress"} + typeOptions = []string{"bug", "feature", "task", "chore"} + statusOptions = []string{"open", "closed", "in_progress", "blocked", "ready_to_sprint"} priorityRange = []string{"0", "1", "2", "3", "4"} ) diff --git a/internal/commands/issues/create.go b/internal/commands/issues/create.go index 8e1eb7e..238e295 100644 --- a/internal/commands/issues/create.go +++ b/internal/commands/issues/create.go @@ -6,7 +6,6 @@ import ( "github.com/LazyBachelor/LazyPM/internal/models" "github.com/LazyBachelor/LazyPM/internal/utils/shellcomp" - "github.com/charmbracelet/huh" "github.com/spf13/cobra" ) @@ -35,12 +34,14 @@ var CreateCmd = &cobra.Command{ func runCreateCmd(cmd *cobra.Command, args []string) error { createFlags.title = strings.Join(args, " ") - // Run interactive if flag is set - if createFlags.interactive { - if err := runCreateInteractive(); err != nil { - return err + /* + // Run interactive if flag is set + if createFlags.interactive { + if err := runCreateInteractive(); err != nil { + return err + } } - } + */ if createFlags.title == "" { return fmt.Errorf("issue title cannot be empty") @@ -52,11 +53,12 @@ func runCreateCmd(cmd *cobra.Command, args []string) error { Status: models.Status(createFlags.status), IssueType: models.IssueType(createFlags.issueType), Priority: createFlags.priority, + Assignee: createFlags.assignee, } // Create the issue using the service layer. app := AppFromContext(cmd.Context()) - err := app.Issues.CreateIssue(cmd.Context(), issue, "test_actor") + err := app.Issues.CreateIssue(cmd.Context(), issue, "") if err != nil { return fmt.Errorf("error creating issue: %w", err) } @@ -67,6 +69,7 @@ func runCreateCmd(cmd *cobra.Command, args []string) error { return nil } +/* Does not align with cli best practices // runCreateInteractive runs the interactive mode for creating issues, // allowing users to input issue details through a form. func runCreateInteractive() error { @@ -103,14 +106,16 @@ func runCreateInteractive() error { return form.Run() } +*/ // init function to set up the create command and its flags. func init() { - CreateCmd.Flags().BoolVarP(&createFlags.interactive, "interactive", "i", false, "Create issue interactively") + //CreateCmd.Flags().BoolVarP(&createFlags.interactive, "interactive", "i", false, "Create issue interactively") CreateCmd.Flags().StringVarP(&createFlags.description, "desc", "d", "", "Issue description") - CreateCmd.Flags().StringVarP(&createFlags.status, "status", "s", "open", "Issue status(open, closed, in_progress)") + CreateCmd.Flags().StringVarP(&createFlags.status, "status", "s", "open", "Issue status(open, closed, in_progress, ready_to_sprint)") CreateCmd.Flags().StringVarP(&createFlags.issueType, "type", "t", "task", "Issue type(bug, feature, task)") CreateCmd.Flags().IntVarP(&createFlags.priority, "priority", "p", 0, "Issue priority(0-4)") + CreateCmd.Flags().StringVarP(&createFlags.assignee, "assignee", "a", "", "Issue assignee") CreateCmd.RegisterFlagCompletionFunc("type", shellcomp.CompletionFunc(typeOptions)) CreateCmd.RegisterFlagCompletionFunc("status", shellcomp.CompletionFunc(statusOptions)) diff --git a/internal/commands/issues/list.go b/internal/commands/issues/list.go index 8b8c451..e9eae24 100644 --- a/internal/commands/issues/list.go +++ b/internal/commands/issues/list.go @@ -54,6 +54,9 @@ func runGetIssuesCmd(cmd *cobra.Command, args []string) error { if cmd.Flags().Changed("priority") { filter.Priority = &listFlags.priority } + if cmd.Flags().Changed("assignee") { + filter.Assignee = &listFlags.assignee + } // Fetch issues based on the search query and filters. app := AppFromContext(cmd.Context()) @@ -73,9 +76,10 @@ func runGetIssuesCmd(cmd *cobra.Command, args []string) error { func init() { ListCmd.Flags().StringVar(&listFlags.title, "title", "", "Filter issues by title") ListCmd.Flags().StringVarP(&listFlags.description, "desc", "d", "", "Filter issues by description") - ListCmd.Flags().StringVarP(&listFlags.status, "status", "s", "", "Filter issues by status (open, closed, in_progress)") + ListCmd.Flags().StringVarP(&listFlags.status, "status", "s", "", "Filter issues by status (open, closed, in_progress, ready_to_sprint)") ListCmd.Flags().StringVarP(&listFlags.issueType, "type", "t", "", "Filter issues by type (bug, feature, task)") ListCmd.Flags().IntVarP(&listFlags.priority, "priority", "p", 0, "Filter issues by priority (0-4)") + ListCmd.Flags().StringVarP(&listFlags.assignee, "assignee", "a", "", "Filter issues by assignee") ListCmd.Flags().IntVarP(&listFlags.limit, "limit", "l", 25, "Limit the number of issues returned") diff --git a/internal/commands/issues/root.go b/internal/commands/issues/root.go index 22e242f..989bd9c 100644 --- a/internal/commands/issues/root.go +++ b/internal/commands/issues/root.go @@ -28,6 +28,7 @@ type Flags struct { status string issueType string priority int + assignee string } // RootCmd is the base command for the CLI application. diff --git a/internal/commands/issues/update.go b/internal/commands/issues/update.go index eaaceb4..29a6b29 100644 --- a/internal/commands/issues/update.go +++ b/internal/commands/issues/update.go @@ -58,9 +58,10 @@ func runUpdateCmd(cmd *cobra.Command, args []string) error { func init() { UpdateCmd.Flags().StringVar(&updateFlags.title, "title", "", "New issue title") UpdateCmd.Flags().StringVarP(&updateFlags.description, "desc", "d", "", "New issue description") - UpdateCmd.Flags().StringVarP(&updateFlags.status, "status", "s", "", "New issue status(open, closed, in_progress)") + UpdateCmd.Flags().StringVarP(&updateFlags.status, "status", "s", "", "New issue status(open, closed, in_progress, ready_to_sprint)") UpdateCmd.Flags().StringVarP(&updateFlags.issueType, "type", "t", "", "New issue type(bug, feature, task)") - UpdateCmd.Flags().IntVarP(&updateFlags.priority, "priority", "p", 0, "New issue priority(0-5)") + UpdateCmd.Flags().IntVarP(&updateFlags.priority, "priority", "p", 0, "New issue priority(0-4)") + UpdateCmd.Flags().StringVarP(&updateFlags.assignee, "assignee", "a", "", "New issue assignee") UpdateCmd.RegisterFlagCompletionFunc("type", shellcomp.CompletionFunc(typeOptions)) UpdateCmd.RegisterFlagCompletionFunc("status", shellcomp.CompletionFunc(statusOptions)) @@ -93,6 +94,10 @@ func getUpdateValues(cmd *cobra.Command) (map[string]interface{}, error) { updates["priority"] = updateFlags.priority } + if cmd.Flags().Changed("assignee") { + updates["assignee"] = updateFlags.assignee + } + if len(updates) == 0 { return updates, fmt.Errorf("no updates specified") } diff --git a/internal/commands/survey/root.go b/internal/commands/survey/root.go index 41a4d21..f148f08 100644 --- a/internal/commands/survey/root.go +++ b/internal/commands/survey/root.go @@ -26,11 +26,6 @@ Your responses will be kept confidential and used solely for research purposes.` var RootCmd = &cobra.Command{ Use: "survey", Long: long, - PersistentPreRun: func(cmd *cobra.Command, args []string) { - if app != nil { - cmd.SetContext(context.WithValue(cmd.Context(), appKey, app)) - } - }, } func SetApp(application *App) { diff --git a/internal/commands/survey/start.go b/internal/commands/survey/start.go index bd2676b..a7a9cfd 100644 --- a/internal/commands/survey/start.go +++ b/internal/commands/survey/start.go @@ -7,6 +7,7 @@ import ( ) var ( + DevFlag bool InterfaceType string Task string ) @@ -22,4 +23,5 @@ func init() { StartCmd.Flags().StringVarP(&InterfaceType, "interface", "i", "", "Specify interface.") StartCmd.RegisterFlagCompletionFunc("task", shellcomp.CompletionFunc(task.ListTasks())) StartCmd.RegisterFlagCompletionFunc("interface", shellcomp.CompletionFunc(task.ListInterfaces())) + StartCmd.Flags().BoolVar(&DevFlag, "dev", false, "Enable development mode, which skips database connection, submission and intro") } diff --git a/internal/commands/survey/submit.go b/internal/commands/survey/submit.go index 8d0a7b2..bcdd77a 100644 --- a/internal/commands/survey/submit.go +++ b/internal/commands/survey/submit.go @@ -1,123 +1,39 @@ package survey import ( - "encoding/json" "fmt" - "os" - "strings" - "github.com/LazyBachelor/LazyPM/internal/models" + "github.com/LazyBachelor/LazyPM/internal/storage" "github.com/spf13/cobra" - "go.mongodb.org/mongo-driver/mongo" - "go.mongodb.org/mongo-driver/mongo/options" ) -var mongoURI = os.Getenv("MONGODB_URI") - var SubmitCmd = &cobra.Command{ Use: "submit", Short: "Submit your survey responses", RunE: func(cmd *cobra.Command, args []string) error { - client, error := mongo.Connect(cmd.Context(), options.Client().ApplyURI(mongoURI)) - if error != nil { - return fmt.Errorf("Failed to connect to MongoDB: %v", error) + app := AppFromContext(cmd.Context()) + + if app == nil { + return fmt.Errorf("application context not initialized") } - go func() { - if err := client.Disconnect(cmd.Context()); err != nil { - fmt.Printf("Failed to disconnect MongoDB client: %v", err) - } - }() + if app.Config.DbUri == "" { + cmd.Println("No database URI provided in environment, survey responses will not be submitted.") + return nil + } - userStatscollection := client.Database("Responses").Collection("stats") - taskMetricsCollection := client.Database("Responses").Collection("task_metrics") - - pmDir := "./.pm/" - - entries, err := os.ReadDir(pmDir) + db, err := storage.NewMongoStorageInteractive(cmd.Context(), app.Config.DbUri) if err != nil { - return fmt.Errorf("Failed to read .pm directory: %v", err) + return fmt.Errorf("failed to connect to database: %w", err) } - if len(entries) == 0 { - return fmt.Errorf("No files found in .pm directory") + defer db.Close() + + if err := db.SubmitSurveyResponsesCmd(cmd.Context(), app.Config.AppDir); err != nil { + return fmt.Errorf("failed to submit survey responses: %w", err) } - statFile := pmDir + "stats.json" - if _, err := os.Stat(statFile); os.IsNotExist(err) { - return fmt.Errorf("stats.json not found in .pm directory") - } - - stats, err := getStats(statFile) - if err != nil { - return fmt.Errorf("Failed to read stats.json: %v", err) - } - - _, err = userStatscollection.InsertOne(cmd.Context(), stats) - if err != nil { - return fmt.Errorf("Failed to insert stats into database: %v", err) - } - - metricFiles := []string{} - for _, entry := range entries { - if entry.IsDir() { - continue - } - - if entry.Name() == "stats.json" { - continue - } - - if strings.HasSuffix(entry.Name(), "-stats.json") { - metricFiles = append(metricFiles, pmDir+entry.Name()) - continue - } - } - - for _, file := range metricFiles { - metrics, err := getTaskMetrics(file) - if err != nil { - fmt.Printf("failed to read metrics from %s: %v", file, err) - continue - } - - _, err = taskMetricsCollection.InsertOne(cmd.Context(), metrics) - if err != nil { - fmt.Printf("failed to insert metrics from %s: %v", file, err) - continue - } - } - - cmd.Printf("Successfully submitted survey responses and metrics to the database") - + cmd.Println("Successfully submitted survey responses and metrics to the database") return nil }, } - -func getStats(file string) (*models.Statistics, error) { - data, err := os.ReadFile(file) - if err != nil { - return nil, err - } - - var stats models.Statistics - if err := json.Unmarshal(data, &stats); err != nil { - return nil, err - } - - return &stats, nil -} - -func getTaskMetrics(file string) (*models.TaskMetricsFile, error) { - data, err := os.ReadFile(file) - if err != nil { - return nil, err - } - - var metrics models.TaskMetricsFile - if err := json.Unmarshal(data, &metrics); err != nil { - return nil, err - } - - return &metrics, nil -} diff --git a/internal/models/app.go b/internal/models/app.go index 5ba33b1..e1e5603 100644 --- a/internal/models/app.go +++ b/internal/models/app.go @@ -3,6 +3,8 @@ package models import ( "context" "log/slog" + + "go.mongodb.org/mongo-driver/bson/primitive" ) type App struct { @@ -57,5 +59,7 @@ type StatsService interface { Load(ctx context.Context) error Save(ctx context.Context) error GetStatistics() (Statistics, error) + GetParticipantID() primitive.ObjectID RecordTaskRun(ctx context.Context, run TaskRunMetrics) error + RecordIntroQuestionnaireAnswers(answers map[string]any) error } diff --git a/internal/models/beads.go b/internal/models/beads.go index 4f98234..624ed6d 100644 --- a/internal/models/beads.go +++ b/internal/models/beads.go @@ -3,8 +3,11 @@ package models import ( "fmt" "os" + "strings" "text/tabwriter" + "unicode" + "charm.land/lipgloss/v2" "github.com/muesli/reflow/truncate" "github.com/steveyegge/beads" ) @@ -32,11 +35,12 @@ type ( // Status constants const ( - StatusOpen = beads.StatusOpen - StatusInProgress = beads.StatusInProgress - StatusBlocked = beads.StatusBlocked - StatusDeferred = beads.StatusDeferred - StatusClosed = beads.StatusClosed + StatusOpen = beads.StatusOpen + StatusInProgress = beads.StatusInProgress + StatusBlocked = beads.StatusBlocked + StatusDeferred = beads.StatusDeferred + StatusClosed = beads.StatusClosed + StatusReadyToSprint Status = "ready_to_sprint" ) // IssueType constants @@ -80,15 +84,26 @@ const ( ) func IssueString(issue Issue) string { - return fmt.Sprintf( - "ID: %s\nTitle: %s\nDescription: %s\nStatus: %s\nType: %s\nPriority: %d", - issue.ID, - issue.Title, - issue.Description, - issue.Status, - issue.IssueType, - issue.Priority, + labelStyle := lipgloss.NewStyle(). + Bold(true).Foreground(lipgloss.Color("5")) + + titleStyle := lipgloss.NewStyle(). + Bold(true).Foreground(lipgloss.Color("6")) + + boxStyle := lipgloss.NewStyle().Padding(1) + + line := func(label, value string) string { + return labelStyle.Render(label) + ": " + value + "\t" + } + + content := lipgloss.JoinVertical( + lipgloss.Left, + line("Title", titleStyle.Render(issue.Title)+"\t"+line("Assignee", issue.Assignee)), + line("ID", issue.ID+"\t"+line("Type", string(issue.IssueType))+line("Status", string(issue.Status))+line("Priority", fmt.Sprintf("%d", issue.Priority))), + line("Description", "\n"+issue.Description), ) + + return boxStyle.Render(content) } func IssuesPtrToIssues(issuePtr []*Issue) []Issue { @@ -101,20 +116,39 @@ func IssuesPtrToIssues(issuePtr []*Issue) []Issue { return issues } +func sanitizeCell(s string) string { + s = strings.Map(func(r rune) rune { + switch r { + case '\n', '\r', '\t': + return ' ' + default: + return r + } + }, s) + + return strings.Join(strings.FieldsFunc(s, unicode.IsSpace), " ") +} + func FormatIssueRow(issue Issue) string { + id := truncate.String(sanitizeCell(issue.ID), 10) + title := truncate.StringWithTail(sanitizeCell(issue.Title), 35, "...") + description := truncate.StringWithTail(sanitizeCell(issue.Description), 40, "...") + status := sanitizeCell(string(issue.Status)) + issueType := sanitizeCell(string(issue.IssueType)) + return fmt.Sprintf( "%s\t%s\t%s\t%s\t%s\t%d", - truncate.String(issue.ID, 10), - truncate.StringWithTail(issue.Title, 25, "..."), - truncate.StringWithTail(issue.Description, 40, "..."), - issue.Status, - issue.IssueType, + id, + title, + description, + status, + issueType, issue.Priority, ) } func PrintIssues(issues []Issue) { - w := tabwriter.NewWriter(os.Stdout, 8, 10, 5, ' ', 0) + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) fmt.Fprintln(w, "ID\tTITLE\tDESCRIPTION\tSTATUS\tTYPE\tPRIORITY") @@ -122,5 +156,5 @@ func PrintIssues(issues []Issue) { fmt.Fprintln(w, FormatIssueRow(issue)) } - w.Flush() + _ = w.Flush() } diff --git a/internal/models/config.go b/internal/models/config.go index 48a7f62..4bbf09d 100644 --- a/internal/models/config.go +++ b/internal/models/config.go @@ -1,24 +1,40 @@ package models +import "os" + type Config struct { + DbUri string AutoInit bool RootCmd string + AppDir string WebAddress string - BeadsDBPath string IssuePrefix string - StatisticsStoragePath string ActionLogger func(string) + StatisticsStoragePath string } var BaseConfig = Config{ + DbUri: "", AutoInit: false, RootCmd: "pm", IssuePrefix: "pm", WebAddress: ":8080", - BeadsDBPath: "./.pm/db.db", + AppDir: "./.pm/", StatisticsStoragePath: "./.pm/stats.json", } +func (c Config) LoadFromEnv() Config { + if dbURI, ok := os.LookupEnv("DB_URI"); ok { + c.DbUri = dbURI + } + return c +} + +func (c Config) WithDbUri(uri string) Config { + c.DbUri = uri + return c +} + func (c Config) WithAutoInit(autoInit bool) Config { c.AutoInit = autoInit return c @@ -34,11 +50,6 @@ func (c Config) WithWebAddress(webAddress string) Config { return c } -func (c Config) WithBeadsDBPath(beadsDBPath string) Config { - c.BeadsDBPath = beadsDBPath - return c -} - func (c Config) WithIssuePrefix(issuePrefix string) Config { c.IssuePrefix = issuePrefix return c diff --git a/internal/models/statistics.go b/internal/models/statistics.go index b151072..fe4c8df 100644 --- a/internal/models/statistics.go +++ b/internal/models/statistics.go @@ -2,91 +2,98 @@ package models import ( "time" + + "go.mongodb.org/mongo-driver/bson/primitive" ) type Statistics struct { - ID int `json:"id"` - StartTime time.Time `json:"start_time"` - EndTime time.Time `json:"end_time"` - Duration time.Duration `json:"duration"` + ID primitive.ObjectID `bson:"_id" json:"id"` + StartTime time.Time `bson:"start_time" json:"start_time"` + EndTime time.Time `bson:"end_time" json:"end_time"` + DurationMs int64 `bson:"duration_ms" json:"duration_ms"` - InterfaceType InterfaceType `json:"interface_type"` - TaskRuns int `json:"task_runs"` - TasksCompleted int `json:"tasks_completed"` - TasksFailed int `json:"tasks_failed"` + LastInterfaceType InterfaceType `bson:"last_interface_type" json:"last_interface_type"` + TaskRuns int `bson:"task_runs" json:"task_runs"` + TasksCompleted int `bson:"tasks_completed" json:"tasks_completed"` + TasksFailed int `bson:"tasks_failed" json:"tasks_failed"` - TotalDurationMs int64 `json:"total_duration_ms"` - AverageDurationMs int64 `json:"average_duration_ms"` + TotalDurationMs int64 `bson:"total_duration_ms" json:"total_duration_ms"` + AverageDurationMs int64 `bson:"average_duration_ms" json:"average_duration_ms"` - TotalUserActions int `json:"total_user_actions"` - QuestionnairesCompleted int `json:"questionnaires_completed"` - QuestionnairesAbandoned int `json:"questionnaires_abandoned"` + TotalUserActions int `bson:"total_user_actions" json:"total_user_actions"` - ValidationAttempts int `json:"validation_attempts"` - ValidationSuccesses int `json:"validation_successes"` - ValidationFailures int `json:"validation_failures"` - ValidationChecksPassed int `json:"validation_checks_passed"` - ValidationChecksFailed int `json:"validation_checks_failed"` + IntroQuestionnaireAnswers map[string]any `bson:"intro_questionnaire_answers" json:"intro_questionnaire_answers"` - LastTaskName string `json:"last_task_name"` - LastRunID int `json:"last_run_id"` + QuestionnairesCompleted int `bson:"questionnaires_completed" json:"questionnaires_completed"` + QuestionnairesAbandoned int `bson:"questionnaires_abandoned" json:"questionnaires_abandoned"` + + ValidationAttempts int `bson:"validation_attempts" json:"validation_attempts"` + ValidationSuccesses int `bson:"validation_successes" json:"validation_successes"` + ValidationFailures int `bson:"validation_failures" json:"validation_failures"` + ValidationChecksPassed int `bson:"validation_checks_passed" json:"validation_checks_passed"` + ValidationChecksFailed int `bson:"validation_checks_failed" json:"validation_checks_failed"` + + LastTaskName string `bson:"last_task_name" json:"last_task_name"` + LastRunID int `bson:"last_run_id" json:"last_run_id"` } type TaskMetricsFile struct { - TaskName string `json:"task_name"` - UpdatedAt time.Time `json:"updated_at"` - Summary TaskStatsSummary `json:"summary"` - Runs []TaskRunMetrics `json:"runs"` + ID primitive.ObjectID `bson:"_id" json:"id"` + ParticipantID primitive.ObjectID `bson:"participant_id" json:"participant_id"` + TaskName string `bson:"task_name" json:"task_name"` + UpdatedAt time.Time `bson:"updated_at" json:"updated_at"` + Summary TaskStatsSummary `bson:"summary" json:"summary"` + Runs []TaskRunMetrics `bson:"runs" json:"runs"` } type TaskStatsSummary struct { - TotalRuns int `json:"total_runs"` - CompletedRuns int `json:"completed_runs"` - IncompleteRuns int `json:"incomplete_runs"` - TotalDurationMs int64 `json:"total_duration_ms"` - AverageDurationMs int64 `json:"average_duration_ms"` - TotalUserActions int `json:"total_user_actions"` - QuestionnairesCompleted int `json:"questionnaires_completed"` - QuestionnairesAbandoned int `json:"questionnaires_abandoned"` - ValidationAttempts int `json:"validation_attempts"` - ValidationSuccesses int `json:"validation_successes"` - ValidationFailures int `json:"validation_failures"` - ValidationChecksPassed int `json:"validation_checks_passed"` - ValidationChecksFailed int `json:"validation_checks_failed"` - LastInterfaceType InterfaceType `json:"last_interface_type"` - FirstRunStartedAt time.Time `json:"first_run_started_at"` - LastRunStartedAt time.Time `json:"last_run_started_at"` - LastRunEndedAt time.Time `json:"last_run_ended_at"` + TotalRuns int `bson:"total_runs" json:"total_runs"` + CompletedRuns int `bson:"completed_runs" json:"completed_runs"` + IncompleteRuns int `bson:"incomplete_runs" json:"incomplete_runs"` + TotalDurationMs int64 `bson:"total_duration_ms" json:"total_duration_ms"` + AverageDurationMs int64 `bson:"average_duration_ms" json:"average_duration_ms"` + TotalUserActions int `bson:"total_user_actions" json:"total_user_actions"` + QuestionnairesCompleted int `bson:"questionnaires_completed" json:"questionnaires_completed"` + QuestionnairesAbandoned int `bson:"questionnaires_abandoned" json:"questionnaires_abandoned"` + ValidationAttempts int `bson:"validation_attempts" json:"validation_attempts"` + ValidationSuccesses int `bson:"validation_successes" json:"validation_successes"` + ValidationFailures int `bson:"validation_failures" json:"validation_failures"` + ValidationChecksPassed int `bson:"validation_checks_passed" json:"validation_checks_passed"` + ValidationChecksFailed int `bson:"validation_checks_failed" json:"validation_checks_failed"` + LastInterfaceType InterfaceType `bson:"last_interface_type" json:"last_interface_type"` + FirstRunStartedAt time.Time `bson:"first_run_started_at" json:"first_run_started_at"` + LastRunStartedAt time.Time `bson:"last_run_started_at" json:"last_run_started_at"` + LastRunEndedAt time.Time `bson:"last_run_ended_at" json:"last_run_ended_at"` } type TaskRunMetrics struct { - RunID int `json:"run_id"` - TaskName string `json:"task_name"` - InterfaceType InterfaceType `json:"interface_type"` - StartedAt time.Time `json:"started_at"` - EndedAt time.Time `json:"ended_at"` - DurationMs int64 `json:"duration_ms"` - Completed bool `json:"completed"` - ValidationAttempts int `json:"validation_attempts"` - ValidationSuccesses int `json:"validation_successes"` - ValidationFailures int `json:"validation_failures"` - ValidationChecksPassed int `json:"validation_checks_passed"` - ValidationChecksFailed int `json:"validation_checks_failed"` - LastValidationMessage string `json:"last_validation_message,omitempty"` - QuestionnaireCompleted bool `json:"questionnaire_completed"` - QuestionnaireUserQuit bool `json:"questionnaire_user_quit"` - QuestionnaireAnswers map[string]any `json:"questionnaire_answers,omitempty"` - Logs []TaskLogEntry `json:"logs"` - Error string `json:"error,omitempty"` + RunID int `bson:"run_id" json:"run_id"` + TaskName string `bson:"task_name" json:"task_name"` + InterfaceType InterfaceType `bson:"interface_type" json:"interface_type"` + StartedAt time.Time `bson:"started_at" json:"started_at"` + EndedAt time.Time `bson:"ended_at" json:"ended_at"` + DurationMs int64 `bson:"duration_ms" json:"duration_ms"` + Completed bool `bson:"completed" json:"completed"` + ValidationAttempts int `bson:"validation_attempts" json:"validation_attempts"` + ValidationSuccesses int `bson:"validation_successes" json:"validation_successes"` + ValidationFailures int `bson:"validation_failures" json:"validation_failures"` + ValidationChecksPassed int `bson:"validation_checks_passed" json:"validation_checks_passed"` + ValidationChecksFailed int `bson:"validation_checks_failed" json:"validation_checks_failed"` + LastValidationMessage string `bson:"last_validation_message,omitempty" json:"last_validation_message,omitempty"` + QuestionnaireCompleted bool `bson:"questionnaire_completed" json:"questionnaire_completed"` + QuestionnaireUserQuit bool `bson:"questionnaire_user_quit" json:"questionnaire_user_quit"` + QuestionnaireAnswers map[string]any `bson:"questionnaire_answers,omitempty" json:"questionnaire_answers,omitempty"` + Logs []TaskLogEntry `bson:"logs" json:"logs"` + Error string `bson:"error,omitempty" json:"error,omitempty"` } type TaskLogEntry struct { - Timestamp time.Time `json:"timestamp"` - Level string `json:"level"` - Message string `json:"message"` - Source string `json:"source,omitempty"` - Action string `json:"action,omitempty"` - Target string `json:"target,omitempty"` - Result string `json:"result,omitempty"` - Attempt int `json:"attempt,omitempty"` + Timestamp time.Time `bson:"timestamp" json:"timestamp"` + Level string `bson:"level" json:"level"` + Message string `bson:"message" json:"message"` + Source string `bson:"source,omitempty" json:"source,omitempty"` + Action string `bson:"action,omitempty" json:"action,omitempty"` + Target string `bson:"target,omitempty" json:"target,omitempty"` + Result string `bson:"result,omitempty" json:"result,omitempty"` + Attempt int `bson:"attempt,omitempty" json:"attempt,omitempty"` } diff --git a/internal/models/task.go b/internal/models/task.go index 88d385a..50ad3e5 100644 --- a/internal/models/task.go +++ b/internal/models/task.go @@ -8,7 +8,7 @@ import ( type Tasker interface { Config() Config - Details() TaskDetails + Details(InterfaceType) TaskDetails Setup(context.Context) error Questions(InterfaceType) Questions Validate(context.Context) ValidationFeedback @@ -32,10 +32,12 @@ type ValidatedInterface interface { } type TaskDetails struct { - Title string - Description string - TimeToComplete string - Difficulty string + Title string + Description string + TimeToComplete string + Difficulty string + InterfaceType InterfaceType + InterfaceDescription string } func (td TaskDetails) WithTitle(title string) TaskDetails { @@ -58,6 +60,16 @@ func (td TaskDetails) WithDifficulty(difficulty string) TaskDetails { return td } +func (td TaskDetails) WithInterfaceType(interfaceType InterfaceType) TaskDetails { + td.InterfaceType = interfaceType + return td +} + +func (td TaskDetails) WithInterfaceDescription(desc string) TaskDetails { + td.InterfaceDescription = desc + return td +} + type Questions []*huh.Group func (q Questions) With(group *huh.Group) Questions { diff --git a/internal/storage/beads.go b/internal/storage/beads.go index 3d9ecf9..294da1f 100644 --- a/internal/storage/beads.go +++ b/internal/storage/beads.go @@ -21,6 +21,8 @@ func NewBeadsIssueStorage(ctx context.Context, storage beads.Storage, prefix str } } + storage.SetConfig(ctx, "status.custom", "ready_to_sprint") + return &BeadsService{ Storage: storage, }, nil diff --git a/internal/storage/mongo.go b/internal/storage/mongo.go new file mode 100644 index 0000000..474b819 --- /dev/null +++ b/internal/storage/mongo.go @@ -0,0 +1,180 @@ +package storage + +import ( + "context" + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/LazyBachelor/LazyPM/internal/models" + "github.com/charmbracelet/huh" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +type MongoStorage struct { + client *mongo.Client +} + +func NewMongoStorage(ctx context.Context, uri, username, password string) (*MongoStorage, error) { + credentials := options.Credential{ + Username: username, + Password: password, + } + + client, err := mongo.Connect(ctx, + options.Client().ApplyURI(uri).SetAuth(credentials)) + + if err != nil { + return nil, fmt.Errorf("failed to connect to MongoDB: %v", err) + } + + if err := client.Ping(ctx, nil); err != nil { + return nil, fmt.Errorf("cannot reach MongoDB: %v", err) + } + + return &MongoStorage{client: client}, nil +} + +func NewMongoStorageInteractive(ctx context.Context, uri string) (*MongoStorage, error) { + var username, password string + if os.Getenv("DB_USER") == "" { + if err := huh.NewInput(). + Title("Enter the Database Username"). + Value(&username). + WithTheme(huh.ThemeBase16()).Run(); err != nil { + return nil, fmt.Errorf("failed to read username: %w", err) + } + } else { + username = os.Getenv("DB_USER") + } + + if username == "" { + return nil, fmt.Errorf("No username provided.") + } + + if os.Getenv("DB_PASSWORD") == "" { + if err := huh.NewInput(). + Title("Enter the Survey Password"). + EchoMode(huh.EchoModePassword). + Value(&password). + WithTheme(huh.ThemeBase16()).Run(); err != nil { + return nil, fmt.Errorf("failed to read password: %w", err) + } + } else { + password = os.Getenv("DB_PASSWORD") + } + + if password == "" { + return nil, fmt.Errorf("No password provided.") + + } + + mongoClient, err := NewMongoStorage(ctx, uri, username, password) + if err != nil { + return nil, fmt.Errorf("failed to connect to database: %w", err) + } + return mongoClient, nil +} + +func (s *MongoStorage) Close() error { + return s.client.Disconnect(context.Background()) +} + +func (s *MongoStorage) SubmitSurveyResponsesCmd(ctx context.Context, dir string) error { + pmDir := dir + userStatscollection := s.client.Database("Responses").Collection("stats") + taskMetricsCollection := s.client.Database("Responses").Collection("metrics") + + entries, err := os.ReadDir(pmDir) + if err != nil { + return fmt.Errorf("Failed to read .pm directory: %v", err) + } + + if len(entries) == 0 { + return fmt.Errorf("No files found in .pm directory") + } + + statFile := pmDir + "stats.json" + if _, err := os.Stat(statFile); os.IsNotExist(err) { + return fmt.Errorf("stats.json not found in .pm directory") + } + + stats, err := getStats(statFile) + if err != nil { + return fmt.Errorf("Failed to read stats.json: %v", err) + } + + _, err = userStatscollection.UpdateOne(ctx, + bson.M{"_id": stats.ID}, + bson.M{"$set": stats}, + options.Update().SetUpsert(true)) + + if err != nil { + return fmt.Errorf("Failed to insert stats into database: %v", err) + } + + metricFiles := []string{} + for _, entry := range entries { + if entry.IsDir() { + continue + } + + if entry.Name() == "stats.json" { + continue + } + + if strings.HasSuffix(entry.Name(), "-stats.json") { + metricFiles = append(metricFiles, pmDir+entry.Name()) + continue + } + } + + for _, file := range metricFiles { + metrics, err := getTaskMetrics(file) + if err != nil { + fmt.Printf("failed to read metrics from %s: %v", file, err) + continue + } + + _, err = taskMetricsCollection.UpdateOne(ctx, bson.M{"_id": metrics.ID}, bson.M{"$set": metrics}, + options.Update().SetUpsert(true)) + + if err != nil { + fmt.Printf("failed to insert metrics from %s: %v", file, err) + continue + } + } + + return nil +} + +func getStats(file string) (*models.Statistics, error) { + data, err := os.ReadFile(file) + if err != nil { + return nil, err + } + + var stats models.Statistics + if err := json.Unmarshal(data, &stats); err != nil { + return nil, err + } + + return &stats, nil +} + +func getTaskMetrics(file string) (*models.TaskMetricsFile, error) { + data, err := os.ReadFile(file) + if err != nil { + return nil, err + } + + var metrics models.TaskMetricsFile + if err := json.Unmarshal(data, &metrics); err != nil { + return nil, err + } + + return &metrics, nil +} diff --git a/internal/style/styles.go b/internal/style/styles.go index bd55ae6..85c8b50 100644 --- a/internal/style/styles.go +++ b/internal/style/styles.go @@ -27,4 +27,5 @@ var ( TitleStyle = lipgloss.NewStyle().Foreground(PrimaryColor).Bold(true) TextStyle = lipgloss.NewStyle().Foreground(TextColor) HelpStyle = lipgloss.NewStyle().Align(lipgloss.Center).Foreground(AccentColor) + ErrorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("1")).Bold(true) // Red color for errors ) diff --git a/internal/utils/check/expect.go b/internal/utils/check/expect.go index 8850d83..dbeaa29 100644 --- a/internal/utils/check/expect.go +++ b/internal/utils/check/expect.go @@ -30,11 +30,21 @@ func NewExpector() *Expector { } } +func (e *Expector) Valid() bool { + return len(e.Errors()) == 0 +} + func (e *Expector) Complete() ValidationFeedback { e.Success = len(e.Errors()) == 0 return e.ValidationFeedback } +func (e *Expector) Fatal(message string) ValidationFeedback { + e.Success = false + e.Message = message + return e.ValidationFeedback +} + func (e *Expector) CompleteWithMessage(message string) ValidationFeedback { e.Success = len(e.Errors()) == 0 if !e.Success { diff --git a/notebooks/task-data-analysis.ipynb b/notebooks/task-data-analysis.ipynb deleted file mode 100644 index de212e6..0000000 --- a/notebooks/task-data-analysis.ipynb +++ /dev/null @@ -1,418 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Task Data Analysis Notebook\n", - "\n", - "This notebook loads task run stats from `./.pm` and gives a quick view of completion rates, validation behavior, timings, and common failure patterns." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "8626d9d2", - "metadata": {}, - "outputs": [], - "source": [ - "from pathlib import Path\n", - "import json\n", - "\n", - "import pandas as pd\n", - "import matplotlib.pyplot as plt\n", - "\n", - "pd.set_option(\"display.max_columns\", 200)\n", - "plt.style.use(\"ggplot\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "7bc3e98d", - "metadata": {}, - "outputs": [], - "source": [ - "candidate_dirs = [\n", - " Path.cwd(),\n", - " Path.cwd() / \"./.pm\",\n", - " Path.cwd().parent / \"./.pm\",\n", - "]\n", - "\n", - "stats_dir = None\n", - "stat_files = []\n", - "for d in candidate_dirs:\n", - " if d.is_dir():\n", - " files = sorted(d.glob(\"*-stats.json\"))\n", - " if files:\n", - " stats_dir = d\n", - " stat_files = files\n", - " break\n", - "\n", - "print(f\"Working directory: {Path.cwd()}\")\n", - "print(f\"Using stats directory: {stats_dir}\")\n", - "print(f\"Found {len(stat_files)} stats file(s).\")\n", - "stat_files" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "c453e1ad", - "metadata": {}, - "outputs": [], - "source": [ - "datasets = []\n", - "for file in stat_files:\n", - " with file.open() as f:\n", - " data = json.load(f)\n", - " data[\"_file\"] = file.name\n", - " datasets.append(data)\n", - "\n", - "print(f\"Loaded {len(datasets)} task dataset(s).\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "cbb0dd6b", - "metadata": {}, - "outputs": [], - "source": [ - "summary_rows = []\n", - "run_rows = []\n", - "log_rows = []\n", - "\n", - "for ds in datasets:\n", - " task_name = ds.get(\"task_name\")\n", - " file_name = ds.get(\"_file\")\n", - "\n", - " summary = ds.get(\"summary\", {}).copy()\n", - " summary.update({\"task_name\": task_name, \"file\": file_name})\n", - " summary_rows.append(summary)\n", - "\n", - " for run in ds.get(\"runs\", []):\n", - " row = run.copy()\n", - " row.update({\"task_name\": task_name, \"file\": file_name})\n", - " run_rows.append(row)\n", - "\n", - " for log in run.get(\"logs\", []):\n", - " lrow = log.copy()\n", - " lrow.update({\n", - " \"task_name\": task_name,\n", - " \"file\": file_name,\n", - " \"run_id\": run.get(\"run_id\"),\n", - " \"run_completed\": run.get(\"completed\"),\n", - " })\n", - " log_rows.append(lrow)\n", - "\n", - "summary_df = pd.DataFrame(summary_rows)\n", - "runs_df = pd.DataFrame(run_rows)\n", - "logs_df = pd.DataFrame(log_rows)\n", - "\n", - "for col in [\"updated_at\", \"first_run_started_at\", \"last_run_started_at\", \"last_run_ended_at\"]:\n", - " if col in summary_df.columns:\n", - " summary_df[col] = pd.to_datetime(summary_df[col], errors=\"coerce\")\n", - "\n", - "for col in [\"started_at\", \"ended_at\"]:\n", - " if col in runs_df.columns:\n", - " runs_df[col] = pd.to_datetime(runs_df[col], errors=\"coerce\")\n", - "\n", - "if \"timestamp\" in logs_df.columns:\n", - " logs_df[\"timestamp\"] = pd.to_datetime(logs_df[\"timestamp\"], errors=\"coerce\")\n", - "\n", - "summary_df.shape, runs_df.shape, logs_df.shape" - ] - }, - { - "cell_type": "markdown", - "id": "c974d4f8", - "metadata": {}, - "source": [ - "## Task-level summary" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "92aea6d2", - "metadata": {}, - "outputs": [], - "source": [ - "summary_cols = [\n", - " \"task_name\",\n", - " \"total_runs\",\n", - " \"completed_runs\",\n", - " \"incomplete_runs\",\n", - " \"average_duration_ms\",\n", - " \"validation_attempts\",\n", - " \"validation_successes\",\n", - " \"validation_failures\",\n", - " \"total_user_actions\",\n", - "]\n", - "\n", - "summary_view = summary_df.reindex(columns=summary_cols).copy()\n", - "\n", - "if not summary_view.empty:\n", - " total_runs_nonzero = summary_view[\"total_runs\"].replace({0: pd.NA})\n", - " summary_view[\"completion_rate_pct\"] = (summary_view[\"completed_runs\"] / total_runs_nonzero * 100).round(1)\n", - " summary_view[\"avg_duration_s\"] = (summary_view[\"average_duration_ms\"] / 1000).round(2)\n", - "\n", - "summary_view.sort_values(\"completion_rate_pct\", ascending=False)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "6bd0a081", - "metadata": {}, - "outputs": [], - "source": [ - "if not summary_view.empty and summary_view[\"completion_rate_pct\"].notna().any():\n", - " plot_df = summary_view[[\"task_name\", \"completion_rate_pct\"]].sort_values(\"completion_rate_pct\")\n", - " ax = plot_df.plot(kind=\"barh\", x=\"task_name\", y=\"completion_rate_pct\", legend=False, figsize=(8, 4))\n", - " ax.set_xlabel(\"Completion rate (%)\")\n", - " ax.set_ylabel(\"\")\n", - " ax.set_title(\"Completion rate by task\")\n", - " plt.tight_layout()\n", - "else:\n", - " print(\"No summary data available for plotting.\")" - ] - }, - { - "cell_type": "markdown", - "id": "8d7da541", - "metadata": {}, - "source": [ - "## Run-level diagnostics" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "694e0476", - "metadata": {}, - "outputs": [], - "source": [ - "run_cols = [\n", - " \"task_name\",\n", - " \"run_id\",\n", - " \"interface_type\",\n", - " \"completed\",\n", - " \"duration_ms\",\n", - " \"validation_attempts\",\n", - " \"validation_successes\",\n", - " \"validation_failures\",\n", - " \"questionnaire_completed\",\n", - "]\n", - "\n", - "runs_view = runs_df.reindex(columns=run_cols).copy()\n", - "if \"duration_ms\" in runs_view.columns:\n", - " runs_view[\"duration_s\"] = (runs_view[\"duration_ms\"] / 1000).round(2)\n", - "\n", - "runs_view.sort_values([\"task_name\", \"run_id\"]).head(20)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "7e0aae07", - "metadata": {}, - "outputs": [], - "source": [ - "if not runs_df.empty and {\"task_name\", \"run_id\", \"completed\", \"duration_ms\", \"validation_attempts\"}.issubset(runs_df.columns):\n", - " agg = runs_df.groupby(\"task_name\", dropna=False).agg(\n", - " runs=(\"run_id\", \"count\"),\n", - " completed_runs=(\"completed\", \"sum\"),\n", - " avg_duration_s=(\"duration_ms\", lambda s: round(s.mean() / 1000, 2)),\n", - " median_duration_s=(\"duration_ms\", lambda s: round(s.median() / 1000, 2)),\n", - " avg_validation_attempts=(\"validation_attempts\", \"mean\"),\n", - " )\n", - "\n", - " agg[\"completion_rate_pct\"] = (agg[\"completed_runs\"] / agg[\"runs\"] * 100).round(1)\n", - " agg[\"avg_validation_attempts\"] = agg[\"avg_validation_attempts\"].round(2)\n", - " display(agg.sort_values(\"completion_rate_pct\", ascending=False))\n", - "else:\n", - " print(\"Not enough run data to build aggregate diagnostics.\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "25d3f1a6", - "metadata": {}, - "outputs": [], - "source": [ - "if not runs_df.empty and {\"duration_ms\", \"task_name\"}.issubset(runs_df.columns):\n", - " runs_df.boxplot(column=\"duration_ms\", by=\"task_name\", figsize=(10, 5), rot=20)\n", - " plt.title(\"Run duration distribution by task\")\n", - " plt.suptitle(\"\")\n", - " plt.ylabel(\"Duration (ms)\")\n", - " plt.tight_layout()\n", - "else:\n", - " print(\"No run duration data available for boxplot.\")" - ] - }, - { - "cell_type": "markdown", - "id": "6b89c284", - "metadata": {}, - "source": [ - "## Validation check failure hotspots" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "7e75fb47", - "metadata": {}, - "outputs": [], - "source": [ - "if not logs_df.empty and {\"action\", \"result\", \"task_name\", \"target\"}.issubset(logs_df.columns):\n", - " validation_checks = logs_df[(logs_df[\"action\"] == \"validate_check\") & (logs_df[\"result\"] == \"failed\")].copy()\n", - "\n", - " if not validation_checks.empty:\n", - " failed_by_target = (\n", - " validation_checks\n", - " .groupby([\"task_name\", \"target\"], dropna=False)\n", - " .size()\n", - " .reset_index(name=\"failed_count\")\n", - " .sort_values([\"task_name\", \"failed_count\"], ascending=[True, False])\n", - " )\n", - " display(failed_by_target.groupby(\"task_name\", dropna=False).head(10))\n", - " else:\n", - " print(\"No failed validation checks found.\")\n", - "else:\n", - " print(\"No validation logs available for failure hotspot analysis.\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "e44f3e85", - "metadata": {}, - "outputs": [], - "source": [ - "if not logs_df.empty and {\"action\", \"task_name\", \"run_id\"}.issubset(logs_df.columns):\n", - " attempt_logs = logs_df[logs_df[\"action\"] == \"validate_attempt\"].copy()\n", - "\n", - " if not attempt_logs.empty:\n", - " attempts_per_run = (\n", - " attempt_logs\n", - " .groupby([\"task_name\", \"run_id\"], dropna=False)\n", - " .size()\n", - " .reset_index(name=\"attempt_count\")\n", - " .sort_values([\"task_name\", \"run_id\"])\n", - " )\n", - "\n", - " display(attempts_per_run.head(30))\n", - "\n", - " fig, ax = plt.subplots(figsize=(10, 4))\n", - " for task_name, g in attempts_per_run.groupby(\"task_name\"):\n", - " ax.plot(g[\"run_id\"], g[\"attempt_count\"], marker=\"o\", label=task_name)\n", - "\n", - " ax.set_title(\"Validation attempts per run\")\n", - " ax.set_xlabel(\"Run ID\")\n", - " ax.set_ylabel(\"Validation attempts\")\n", - " ax.legend()\n", - " plt.tight_layout()\n", - " else:\n", - " print(\"No validation attempt logs found.\")\n", - "else:\n", - " print(\"No logs available for attempt trend analysis.\")" - ] - }, - { - "cell_type": "markdown", - "id": "a28fd392", - "metadata": {}, - "source": [ - "## User action endpoint activity" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "6f82ba83", - "metadata": {}, - "outputs": [], - "source": [ - "if not logs_df.empty and {\"level\", \"task_name\", \"target\"}.issubset(logs_df.columns):\n", - " user_actions = logs_df[logs_df[\"level\"] == \"user_action\"].copy()\n", - "\n", - " if not user_actions.empty:\n", - " endpoint_counts = (\n", - " user_actions\n", - " .groupby([\"task_name\", \"target\"], dropna=False)\n", - " .size()\n", - " .reset_index(name=\"count\")\n", - " .sort_values([\"task_name\", \"count\"], ascending=[True, False])\n", - " )\n", - "\n", - " display(endpoint_counts.groupby(\"task_name\", dropna=False).head(15))\n", - " else:\n", - " print(\"No user_action logs found.\")\n", - "else:\n", - " print(\"No logs available for endpoint activity analysis.\")" - ] - }, - { - "cell_type": "markdown", - "id": "32a64572", - "metadata": {}, - "source": [ - "## Quick filters for ad-hoc debugging" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "f8502e4a", - "metadata": {}, - "outputs": [], - "source": [ - "# Example: inspect one task and one run.\n", - "if not runs_df.empty and {\"task_name\", \"run_id\"}.issubset(runs_df.columns):\n", - " task = runs_df[\"task_name\"].iloc[0]\n", - " run_id = 1\n", - "\n", - " print(f\"Task: {task}, run_id: {run_id}\")\n", - " display(runs_df[(runs_df[\"task_name\"] == task) & (runs_df[\"run_id\"] == run_id)])\n", - "\n", - " if not logs_df.empty and {\"task_name\", \"run_id\"}.issubset(logs_df.columns):\n", - " display(logs_df[(logs_df[\"task_name\"] == task) & (logs_df[\"run_id\"] == run_id)].head(50))\n", - "else:\n", - " print(\"No run data available.\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "e6a94bb5", - "metadata": {}, - "outputs": [], - "source": [] - } - ], - "metadata": { - "kernelspec": { - "display_name": ".venv", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.14.3" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/pkg/repl/executor.go b/pkg/repl/executor.go index ff254f4..2789bec 100644 --- a/pkg/repl/executor.go +++ b/pkg/repl/executor.go @@ -21,14 +21,70 @@ func execute(input string) (string, error) { return ReplTitle, nil } + if input == "status" { + return executePMCommand("survey status") + } + if after, ok := strings.CutPrefix(input, "pm"); ok { return executePMCommand(after) } return executeShellCommand(input) } +// shellSplit splits input respecting quoted strings and escapes +// similar to how a shell would parse arguments +func shellSplit(input string) []string { + var args []string + var current strings.Builder + var inQuote rune + var escaped bool + + for _, ch := range input { + if escaped { + current.WriteRune(ch) + escaped = false + continue + } + + if ch == '\\' { + escaped = true + continue + } + + if inQuote != 0 { + if ch == inQuote { + inQuote = 0 + continue + } + current.WriteRune(ch) + continue + } + + if ch == '"' || ch == '\'' { + inQuote = ch + continue + } + + if ch == ' ' || ch == '\t' { + if current.Len() > 0 { + args = append(args, current.String()) + current.Reset() + } + continue + } + + current.WriteRune(ch) + } + + if current.Len() > 0 { + args = append(args, current.String()) + } + + return args +} + func executeShellCommand(input string) (string, error) { - parts := strings.Fields(input) + parts := shellSplit(input) if len(parts) == 0 { return "", nil } @@ -39,7 +95,7 @@ func executeShellCommand(input string) (string, error) { } func executePMCommand(input string) (string, error) { - parts := strings.Fields(input) + parts := shellSplit(input) if len(parts) == 0 { return "", nil } diff --git a/pkg/repl/repl.go b/pkg/repl/repl.go index f91d475..bfc825b 100644 --- a/pkg/repl/repl.go +++ b/pkg/repl/repl.go @@ -2,6 +2,7 @@ package repl import ( + "bufio" "context" "fmt" "os" @@ -12,7 +13,6 @@ import ( "github.com/LazyBachelor/LazyPM/internal/models" "github.com/LazyBachelor/LazyPM/internal/style" "github.com/LazyBachelor/LazyPM/pkg/task" - "github.com/LazyBachelor/LazyPM/pkg/tui/styles" "github.com/c-bata/go-prompt" "golang.org/x/term" ) @@ -22,8 +22,8 @@ type ValidationFeedback = models.ValidationFeedback const ( ReplHelp = `Type 'pm help' for available PM commands. -Type 'pm status' to check task progress. -You can also run shell commands directly. Type 'exit' or 'quit' to leave.` +You can also run shell commands directly. Type 'exit' or 'quit' to leave. +Type 'status' to check task progress.` ReplTitle = "Welcome to Project Management CLI! " + ReplHelp ) @@ -36,6 +36,7 @@ type REPL struct { currentFeedback ValidationFeedback exitRequested bool + taskCompleted bool } func New() *REPL { @@ -44,6 +45,11 @@ func New() *REPL { // Run starts the interactive Read-Eval-Print Loop for the PM CLI. func (r *REPL) Run(ctx context.Context, config app.Config) error { + // Reset state for new task run + r.taskCompleted = false + r.exitRequested = false + r.currentFeedback = ValidationFeedback{} + // Set terminal to raw mode to capture input properly in the REPL. // This allows us to handle input character by character and provide a better user experience. // We also ensure that the terminal state is restored when the REPL exits, even if an error occurs. @@ -82,7 +88,18 @@ func (r *REPL) Run(ctx context.Context, config app.Config) error { var history []string // Start the REPL loop, which continues until the user types "exit" or "quit" or task completes. + reader := bufio.NewReader(os.Stdin) for !r.exitRequested { + // Check if task completed - wait for Enter before exiting + if r.taskCompleted { + fmt.Println(style.TitleStyle.Render("Task completed successfully!")) + fmt.Print("Press Enter to exit...") + // Restore terminal to normal mode for input + term.Restore(int(os.Stdin.Fd()), oldState) + reader.ReadString('\n') + break + } + // Check if we should exit before prompting (non-blocking check) if r.exitRequested { break @@ -100,6 +117,16 @@ func (r *REPL) Run(ctx context.Context, config app.Config) error { break } + // Check if task completed while waiting at prompt + if r.taskCompleted { + fmt.Println(style.TitleStyle.Render("Task completed successfully!")) + fmt.Print("Press Enter to exit...") + // Restore terminal to normal mode for input + term.Restore(int(os.Stdin.Fd()), oldState) + reader.ReadString('\n') + break + } + // Trim whitespace from the input to ensure consistent command processing. input = strings.TrimSpace(input) @@ -117,8 +144,19 @@ func (r *REPL) Run(ctx context.Context, config app.Config) error { // Add the input to the history for future navigation. history = append(history, input) - output, _ := execute(input) // Ignore errors for now, gives better ux - fmt.Println(style.TextStyle.Render(output)) // Print the output of the command in a styled format. + output, err := execute(input) + if err != nil { + // Show command output (even on error) in normal text style + if output != "" { + fmt.Println(style.TextStyle.Render(output)) + } + // Show error message in red if no output was captured + if output == "" { + fmt.Println(style.ErrorStyle.Render(err.Error())) + } + } else if output != "" { + fmt.Println(style.TextStyle.Render(output)) + } } return nil @@ -138,9 +176,7 @@ func (r *REPL) watchValidation() { } } if feedback.Success { - fmt.Printf("\n%s\n", styles.TitleStyle.Render("Task completed successfully!")) - fmt.Println("Press Enter to exit...") - r.exitRequested = true + r.taskCompleted = true return } case <-r.quitChan: diff --git a/pkg/repl/suggestions.go b/pkg/repl/suggestions.go index 360791d..4824a66 100644 --- a/pkg/repl/suggestions.go +++ b/pkg/repl/suggestions.go @@ -12,6 +12,7 @@ import ( // rootSuggestions is a list of prompt suggestions for root-level commands. var rootSuggestions = []prompt.Suggest{ {Text: "pm", Description: "Project Management System"}, + {Text: "status", Description: "Show task status"}, {Text: "exit", Description: "Exit pm CLI"}, {Text: "help", Description: "Show help information"}, {Text: "title", Description: "Print the welcome title"}, @@ -21,24 +22,23 @@ var rootSuggestions = []prompt.Suggest{ // commandSuggestions is a list of prompt suggestions for PM commands. var baseSuggestions = []prompt.Suggest{ {Text: "help", Description: "Show help information"}, - {Text: "status", Description: "Show task status"}, - {Text: "delete", Description: "Delete an issue by ID"}, - {Text: "close", Description: "Close an issue by ID"}, {Text: "create", Description: "Create a new issue with title"}, - {Text: "update", Description: "Update an existing issue by ID"}, {Text: "describe", Description: "Get issue details by ID"}, {Text: "list", Description: "List all issues"}, + {Text: "close", Description: "Close an issue by ID"}, + {Text: "update", Description: "Update an existing issue by ID"}, + {Text: "delete", Description: "Delete an issue by ID"}, {Text: "comment", Description: "Add a comment on an issue by ID"}, {Text: "comments", Description: "List comments on an issue by ID"}, } // createFlags is a list of prompt suggestions for the create command flags. var createFlags = []prompt.Suggest{ - {Text: "--interactive", Description: "Create issue interactively"}, {Text: "--desc", Description: "Issue description"}, {Text: "--status", Description: "Issue status (open, closed, in_progress)"}, {Text: "--type", Description: "Issue type (bug, feature, task)"}, - {Text: "--priority", Description: "Issue priority (0-5)"}, + {Text: "--priority", Description: "Issue priority (0-4)"}, + {Text: "--assignee", Description: "Issue assignee"}, } // updateFlags is a list of prompt suggestions for the update command flags. @@ -47,7 +47,8 @@ var updateFlags = []prompt.Suggest{ {Text: "--desc", Description: "New issue description"}, {Text: "--status", Description: "New issue status (open, closed, in_progress)"}, {Text: "--type", Description: "New issue type (bug, feature, task)"}, - {Text: "--priority", Description: "New issue priority (0-5)"}, + {Text: "--priority", Description: "New issue priority (0-4)"}, + {Text: "--assignee", Description: "New issue assignee"}, } // listFlags is a list of prompt suggestions for the list command flags. @@ -56,7 +57,8 @@ var listFlags = []prompt.Suggest{ {Text: "--desc", Description: "Filter by description"}, {Text: "--status", Description: "Filter by status (open, closed, in_progress)"}, {Text: "--type", Description: "Filter by type (bug, feature, task)"}, - {Text: "--priority", Description: "Filter by priority (0-5)"}, + {Text: "--priority", Description: "Filter by priority (0-4)"}, + {Text: "--assignee", Description: "Filter by assignee"}, {Text: "--limit", Description: "Limit number of results"}, } @@ -77,22 +79,25 @@ var statusValues = []prompt.Suggest{ {Text: "open", Description: "Open status"}, {Text: "closed", Description: "Closed status"}, {Text: "in_progress", Description: "In progress status"}, + {Text: "blocked", Description: "Blocked status"}, + {Text: "ready_to_sprint", Description: "Ready to sprint status"}, } // typeValues is a list of prompt suggestions for issue types var typeValues = []prompt.Suggest{ + {Text: "task", Description: "Task issue type"}, {Text: "bug", Description: "Bug issue type"}, {Text: "feature", Description: "Feature issue type"}, - {Text: "task", Description: "Task issue type"}, + {Text: "chore", Description: "Chore issue type"}, } // priorityValues is a list of prompt suggestions for issue priority levels var priorityValues = []prompt.Suggest{ - {Text: "0", Description: "Lowest priority"}, + {Text: "0", Description: "Irrelevant"}, {Text: "1", Description: "Low priority"}, - {Text: "2", Description: "Medium-low priority"}, - {Text: "3", Description: "Medium priority"}, - {Text: "4", Description: "High priority"}, + {Text: "2", Description: "Normal priority"}, + {Text: "3", Description: "High priority"}, + {Text: "4", Description: "Critical priority"}, } // isIDCommand maps command names to a boolean indicating whether they expect an issue ID as an argument. diff --git a/pkg/task/lifecycle.go b/pkg/task/lifecycle.go index 8a9fa29..75b18d9 100644 --- a/pkg/task/lifecycle.go +++ b/pkg/task/lifecycle.go @@ -4,6 +4,7 @@ import ( "log/slog" "github.com/LazyBachelor/LazyPM/internal/models" + "go.mongodb.org/mongo-driver/bson/primitive" ) type RunLifecycle struct { @@ -23,9 +24,11 @@ func NewRunLifecycle(app *App, config Config, details models.TaskDetails, iType collector.recordUserAction(action) }) + var participantID primitive.ObjectID var store MetricsStore if config.StatisticsStoragePath != "" { - store = NewFileMetricsStore(config.StatisticsStoragePath, logger) + participantID = app.Stats.GetParticipantID() + store = NewFileMetricsStore(config.StatisticsStoragePath, participantID, logger) } return &RunLifecycle{ diff --git a/pkg/task/metrics_store.go b/pkg/task/metrics_store.go index ae69f25..a499de8 100644 --- a/pkg/task/metrics_store.go +++ b/pkg/task/metrics_store.go @@ -10,6 +10,7 @@ import ( "time" "github.com/LazyBachelor/LazyPM/internal/models" + "go.mongodb.org/mongo-driver/bson/primitive" ) type MetricsStore interface { @@ -17,14 +18,16 @@ type MetricsStore interface { } type FileMetricsStore struct { - path string - logger *slog.Logger + path string + participantID primitive.ObjectID + logger *slog.Logger } -func NewFileMetricsStore(path string, logger *slog.Logger) *FileMetricsStore { +func NewFileMetricsStore(path string, participantID primitive.ObjectID, logger *slog.Logger) *FileMetricsStore { return &FileMetricsStore{ - path: path, - logger: logger, + path: path, + participantID: participantID, + logger: logger, } } @@ -41,8 +44,10 @@ func (s *FileMetricsStore) Append(ctx context.Context, taskName string, run mode } metrics := models.TaskMetricsFile{ - TaskName: taskName, - Runs: []models.TaskRunMetrics{}, + ID: primitive.NewObjectID(), + ParticipantID: s.participantID, + TaskName: taskName, + Runs: []models.TaskRunMetrics{}, } if err := readMetrics(&metrics, s.path); err != nil { diff --git a/pkg/task/register.go b/pkg/task/register.go index 503a615..14b7767 100644 --- a/pkg/task/register.go +++ b/pkg/task/register.go @@ -5,12 +5,14 @@ import ( ) var interfaceRegistry = make(map[string]Interface) +var interfaceOrder []string func RegisterInterface(name string, iface Interface) { if _, exists := interfaceRegistry[name]; exists { panic(fmt.Sprintf("interface %q already registered", name)) } interfaceRegistry[name] = iface + interfaceOrder = append(interfaceOrder, name) } func GetInterface(name string) (Interface, error) { @@ -22,20 +24,20 @@ func GetInterface(name string) (Interface, error) { } func ListInterfaces() []string { - names := make([]string, 0, len(interfaceRegistry)) - for name := range interfaceRegistry { - names = append(names, name) - } + names := make([]string, len(interfaceOrder)) + copy(names, interfaceOrder) return names } var taskRegistry = make(map[string]func(*App) Tasker) +var taskOrder []string func RegisterTask(name string, constructor func(*App) Tasker) { if _, exists := taskRegistry[name]; exists { panic(fmt.Sprintf("task %q already registered", name)) } taskRegistry[name] = constructor + taskOrder = append(taskOrder, name) } func GetTask(name string, app *App) (Tasker, error) { @@ -47,9 +49,7 @@ func GetTask(name string, app *App) (Tasker, error) { } func ListTasks() []string { - names := make([]string, 0, len(taskRegistry)) - for name := range taskRegistry { - names = append(names, name) - } + names := make([]string, len(taskOrder)) + copy(names, taskOrder) return names } diff --git a/pkg/task/runner.go b/pkg/task/runner.go index 3e25791..0349c84 100644 --- a/pkg/task/runner.go +++ b/pkg/task/runner.go @@ -42,7 +42,7 @@ func NewTaskRunner(app *App) *TaskRunner { func (r *TaskRunner) Run(ctx context.Context, t Tasker, i Interface, iType InterfaceType) (runErr error) { config := t.Config() - details := t.Details() + details := t.Details(iType) lifecycle := NewRunLifecycle(r.app, config, details, iType, r.logger) @@ -110,6 +110,9 @@ func (r *TaskRunner) Run(ctx context.Context, t Tasker, i Interface, iType Inter close(stopChan) close(quitChan) collector.setCompleted(true) + if err := <-interfaceErr; err != nil { + return fmt.Errorf("task interface failed during shutdown: %w", err) + } case err := <-interfaceErr: close(stopChan) diff --git a/pkg/task/taskui.go b/pkg/task/taskui.go index 2f7086b..0d50afd 100644 --- a/pkg/task/taskui.go +++ b/pkg/task/taskui.go @@ -18,11 +18,13 @@ type TaskModel struct { keys TaskHelpKeys width, height int userQuit bool + aboutVisible bool } type TaskHelpKeys struct { Quit key.Binding Start key.Binding + About key.Binding } var DefaultTaskKeys = TaskHelpKeys{ @@ -34,6 +36,10 @@ var DefaultTaskKeys = TaskHelpKeys{ key.WithKeys("enter"), key.WithHelp("enter", "Start"), ), + About: key.NewBinding( + key.WithKeys("?"), + key.WithHelp("?", "Details about the interface"), + ), } func NewTaskModel(details TaskDetails) TaskModel { @@ -58,8 +64,12 @@ func (m TaskModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Quit case key.Matches(msg, m.keys.Start): return m, tea.Quit + case key.Matches(msg, m.keys.About): + m.aboutVisible = !m.aboutVisible + return m, nil } } + return m, nil } @@ -71,10 +81,9 @@ func (m TaskModel) View() string { boxWidth := min(m.width-10, 120) - detailsText := fmt.Sprintf("Time to complete: %s | Difficulty: %s", m.TimeToComplete, m.Difficulty) + detailsText := fmt.Sprintf("Interface Type: %s | Time to complete: %s | Difficulty: %s", m.InterfaceType, m.TimeToComplete, m.Difficulty) - boxStyle := style.BorderStyle. - Margin(1, 0).Padding(2, 4).Width(boxWidth) + boxStyle := style.BorderStyle.Padding(2, 4).Width(boxWidth) var b strings.Builder @@ -88,10 +97,16 @@ func (m TaskModel) View() string { style.TextStyle.Foreground(style.SecondaryColor).Render(detailsText), ) - b.WriteString(boxStyle.Render(content)) + if m.aboutVisible { + b.WriteString(boxStyle.Render(m.InterfaceDescription)) + } else { + b.WriteString(boxStyle.Render(content)) + + } + b.WriteString("\n") - helpText := "Press " + m.keys.Start.Help().Key + " to start • " + m.keys.Quit.Help().Key + " to quit" + helpText := "Press " + m.keys.Start.Help().Key + " to start • " + m.keys.Quit.Help().Key + " to quit • " + m.keys.About.Help().Key + " " + m.keys.About.Help().Desc b.WriteString(style.HelpStyle.Render(helpText)) final := lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, b.String()) diff --git a/pkg/tui/components/helpbar.go b/pkg/tui/components/helpbar.go index 28cb441..c413784 100644 --- a/pkg/tui/components/helpbar.go +++ b/pkg/tui/components/helpbar.go @@ -126,7 +126,8 @@ func helpBarConfig(view ViewKind) HelpBarConfig { {Key: "↓/j", Desc: "down"}, {Key: "pgup/pgdn", Desc: "page"}, {Key: "a", Desc: "add"}, - {Key: "e/d/s/p/t", Desc: "edit"}, + {Key: "c", Desc: "comment"}, + {Key: "e/d/s/p/t/A", Desc: "edit"}, {Key: "x", Desc: "delete"}, {Key: "S", Desc: "submit"}, {Key: "q", Desc: "quit"}, @@ -137,11 +138,13 @@ func helpBarConfig(view ViewKind) HelpBarConfig { {LeftKey: "enter", LeftDesc: "view issue", RightKey: "↓/j", RightDesc: "down"}, {LeftKey: "pgup", LeftDesc: "page up", RightKey: "pgdn", RightDesc: "page down"}, {LeftKey: "b", LeftDesc: "back to list", RightKey: "a", RightDesc: "add issue"}, + {LeftKey: "c", LeftDesc: "add comment", RightKey: "", RightDesc: ""}, {LeftKey: "e", LeftDesc: "edit title", RightKey: "d", RightDesc: "edit description"}, {LeftKey: "s", LeftDesc: "change status", RightKey: "p", RightDesc: "change priority"}, - {LeftKey: "t", LeftDesc: "change type", RightKey: "x", RightDesc: "delete issue"}, - {LeftKey: "v", LeftDesc: "kanban", RightKey: "q", RightDesc: "quit"}, - {LeftKey: "S", LeftDesc: "submit", RightKey: "?", RightDesc: "help"}, + {LeftKey: "t", LeftDesc: "change type", RightKey: "A", RightDesc: "change assignee"}, + {LeftKey: "x", LeftDesc: "delete issue", RightKey: "v", RightDesc: "kanban"}, + {LeftKey: "q", LeftDesc: "quit", RightKey: "S", RightDesc: "submit"}, + {LeftKey: "?", LeftDesc: "help", RightKey: "", RightDesc: ""}, }, } case ViewKanban: @@ -154,7 +157,7 @@ func helpBarConfig(view ViewKind) HelpBarConfig { {Key: "h/l", Desc: "column"}, {Key: "←/→", Desc: "move"}, {Key: "a", Desc: "add"}, - {Key: "e/d/s/p/t", Desc: "edit"}, + {Key: "e/d/s/p/t/A", Desc: "edit"}, {Key: "x", Desc: "delete"}, {Key: "q", Desc: "quit"}, {Key: "S", Desc: "submit"}, @@ -168,9 +171,9 @@ func helpBarConfig(view ViewKind) HelpBarConfig { {LeftKey: "b", LeftDesc: "back to list", RightKey: "a", RightDesc: "add issue"}, {LeftKey: "e", LeftDesc: "edit title", RightKey: "d", RightDesc: "edit description"}, {LeftKey: "s", LeftDesc: "change status", RightKey: "p", RightDesc: "change priority"}, - {LeftKey: "t", LeftDesc: "change type", RightKey: "x", RightDesc: "delete issue"}, - {LeftKey: "q", LeftDesc: "quit", RightKey: "S", RightDesc: "submit"}, - {LeftKey: "?", LeftDesc: "help", RightKey: "", RightDesc: ""}, + {LeftKey: "t", LeftDesc: "change type", RightKey: "A", RightDesc: "change assignee"}, + {LeftKey: "x", LeftDesc: "delete issue", RightKey: "q", RightDesc: "quit"}, + {LeftKey: "S", LeftDesc: "submit", RightKey: "?", RightDesc: "help"}, }, } default: diff --git a/pkg/tui/components/issue_detail.go b/pkg/tui/components/issue_detail.go index d01bde7..9d15b65 100644 --- a/pkg/tui/components/issue_detail.go +++ b/pkg/tui/components/issue_detail.go @@ -67,11 +67,15 @@ func (i *IssueDetail) refreshContent() { styles.LabelStyle.Render("Priority:") + styles.ValueStyle.Render(PriorityCodeName(i.issue.Priority)), ) + assigneeRow := styles.RowStyle.Render( + styles.LabelStyle.Render("Assignee:") + styles.ValueStyle.Render(i.issue.Assignee), + ) + descLabel := styles.LabelStyle.Render("Description:") descContent := styles.ValueStyle.Render(i.issue.Description) var parts []string - parts = append(parts, titleRow, idRow, typeRow, statusRow, priorityRow, descLabel, descContent) + parts = append(parts, titleRow, idRow, typeRow, statusRow, priorityRow, assigneeRow, descLabel, descContent) // Comments section commentsLabel := styles.LabelStyle.Render("Comments:") diff --git a/pkg/tui/components/issue_list.go b/pkg/tui/components/issue_list.go index 1667e34..da81d08 100644 --- a/pkg/tui/components/issue_list.go +++ b/pkg/tui/components/issue_list.go @@ -54,7 +54,7 @@ func getTableColumns(width int) []tableColumn { {width: 20, label: "TITLE", key: "title"}, {width: 15, label: "STATUS", key: "status"}, } - default: + case width < 75: return []tableColumn{ {width: 12, label: "ID", key: "id"}, {width: 20, label: "TITLE", key: "title"}, @@ -62,6 +62,15 @@ func getTableColumns(width int) []tableColumn { {width: 10, label: "TYPE", key: "type"}, {width: 15, label: "PRIORITY", key: "priority"}, } + default: + return []tableColumn{ + {width: 12, label: "ID", key: "id"}, + {width: 20, label: "TITLE", key: "title"}, + {width: 15, label: "STATUS", key: "status"}, + {width: 11, label: "TYPE", key: "type"}, + {width: 14, label: "PRIORITY", key: "priority"}, + {width: 12, label: "ASSIGNEE", key: "assignee"}, + } } } @@ -99,11 +108,12 @@ func renderHeaders(cols []tableColumn) string { return lipgloss.JoinHorizontal(lipgloss.Left, parts...) } -// IssueInputs bundles the common text inputs used for issue title, creation, and description. +// IssueInputs bundles the common text inputs used for issue title, creation, description, and assignee. type IssueInputs struct { Title textinput.Model CreateTitle textinput.Model Description textarea.Model + Assignee textinput.Model } // NewIssueInputs creates initialized inputs for issue title, new issue title, and description. @@ -121,10 +131,15 @@ func NewIssueInputs() IssueInputs { descTa.SetWidth(56) descTa.SetHeight(8) + assigneeTi := textinput.New() + assigneeTi.Placeholder = "Assignee name..." + assigneeTi.CharLimit = 64 + return IssueInputs{ Title: ti, CreateTitle: createTi, Description: descTa, + Assignee: assigneeTi, } } @@ -213,7 +228,10 @@ func OpenAndInProgressOnly(issues []*models.Issue) []*models.Issue { // used to display open & in-progress issues in the first window in the dashboard out := make([]*models.Issue, 0, len(issues)) for _, issue := range issues { - if issue.Status == models.StatusOpen || issue.Status == models.StatusInProgress { + if issue.Status == models.StatusOpen || + issue.Status == models.StatusInProgress || + issue.Status == models.StatusBlocked || + issue.Status == models.StatusReadyToSprint { out = append(out, issue) } } @@ -237,7 +255,8 @@ func ClosedOnly(issues []*models.Issue) []*models.Issue { func StatusOnly(issues []*models.Issue, status models.Status) []*models.Issue { out := make([]*models.Issue, 0, len(issues)) for _, issue := range issues { - if issue.Status == status { + if issue.Status == status || + (status == models.StatusOpen && issue.Status == models.StatusReadyToSprint) { out = append(out, issue) } } @@ -411,6 +430,8 @@ func getColumnValue(col tableColumn, issue ListIssue) string { return string(issue.Issue.IssueType) case "priority": return PriorityCodeName(issue.Issue.Priority) + case "assignee": + return issue.Assignee default: return "" } diff --git a/pkg/tui/components/keymap.go b/pkg/tui/components/keymap.go index c75379a..89ce532 100644 --- a/pkg/tui/components/keymap.go +++ b/pkg/tui/components/keymap.go @@ -16,6 +16,7 @@ type CommonKeyMap struct { ChangeStatus key.Binding ChangePriority key.Binding ChangeType key.Binding + ChangeAssignee key.Binding AddIssue key.Binding DeleteIssue key.Binding } @@ -67,6 +68,10 @@ func DefaultCommonKeyMap() CommonKeyMap { key.WithKeys("t"), key.WithHelp("t", "change type"), ), + ChangeAssignee: key.NewBinding( + key.WithKeys("A"), + key.WithHelp("A", "change assignee"), + ), AddIssue: key.NewBinding( key.WithKeys("a"), key.WithHelp("a", "add issue"), diff --git a/pkg/tui/components/modals.go b/pkg/tui/components/modals.go index b41e3bb..5eee8d7 100644 --- a/pkg/tui/components/modals.go +++ b/pkg/tui/components/modals.go @@ -90,7 +90,7 @@ func RenderChooseStatus(width, height int, issueID string) string { } statusContent := lipgloss.JoinVertical(lipgloss.Left, styles.LabelStyle.Render("Change status for "+issueID+":"), - lipgloss.NewStyle().Foreground(styles.FaintText).Render("o = open i = in_progress c = closed"), + lipgloss.NewStyle().Foreground(styles.FaintText).Render("o = open i = in_progress b = blocked r = ready_to_sprint c = closed"), lipgloss.NewStyle().Foreground(styles.FaintText).Render("Esc = cancel"), ) statusBoxWidth := modalBoxWidth(50, width) @@ -118,6 +118,22 @@ func RenderChoosePriority(width, height int, issueID string) string { return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, priorityBox) } +func RenderEditAssignee(width, height int, inputView string) string { + if width < 5 || height < 5 { + return "" + } + editBoxWidth := modalBoxWidth(60, width) + editContent := lipgloss.JoinVertical(lipgloss.Left, + styles.LabelStyle.Render("Edit assignee (Enter to save, Esc to cancel):"), + inputView, + ) + editBox := styles.ContainerStyle. + Width(editBoxWidth). + BorderForeground(styles.PrimaryBorder). + Render(editContent) + return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, editBox) +} + func RenderChooseType(width, height int, issueID string) string { if width < 5 || height < 5 { return "" @@ -148,6 +164,7 @@ func RenderModals( choosingStatus bool, statusIssueID string, choosingPriority bool, priorityIssueID string, choosingType bool, typeIssueID string, + editingAssignee bool, assigneeInputView string, mainView string, ) string { if editingTitle { @@ -178,6 +195,10 @@ func RenderModals( return RenderChooseType(width, height, typeIssueID) } + if editingAssignee { + return RenderEditAssignee(width, height, assigneeInputView) + } + return mainView } @@ -220,7 +241,29 @@ func RenderFooter(width int, helpBar *HelpBar, feedback models.ValidationFeedbac feedbackStatus := feedback.Message // Ensure the feedback message does not exceed the total available width. - feedbackStatus = truncateToWidth(feedbackStatus, width) + if feedback.Message != "" { + feedbackStatus = truncateToWidth(feedbackStatus+" [Press Shift+S to re-submit]", width) + + if helpBar.IsExpanded() && feedbackStatus != "" { + for _, check := range feedback.Checks { + var prefix string + if check.Valid { + prefix = "✅ " + } else { + prefix = "❌ " + } + + // Ensure each check line does not exceed the available width. + remainingWidth := width - lipgloss.Width(prefix) + if remainingWidth < 0 { + remainingWidth = 0 + } + truncatedMsg := truncateToWidth(check.Message, remainingWidth) + + feedbackStatus += "\n" + prefix + truncatedMsg + } + } + } if feedbackStatus == "" { return helpBar.View() @@ -234,4 +277,3 @@ func RenderFooter(width int, helpBar *HelpBar, feedback models.ValidationFeedbac helpBar.SetWidth(helpWidth) return lipgloss.JoinHorizontal(lipgloss.Left, helpBar.View(), feedbackStatus) } - diff --git a/pkg/tui/issues/operations.go b/pkg/tui/issues/operations.go index 01966fb..cf94913 100644 --- a/pkg/tui/issues/operations.go +++ b/pkg/tui/issues/operations.go @@ -15,6 +15,7 @@ type ( StatusUpdatedMsg struct{ IssueID string; Err error } PriorityUpdatedMsg struct{ IssueID string; Err error } TypeUpdatedMsg struct{ IssueID string; Err error } + AssigneeUpdatedMsg struct{ IssueID string; Err error } SelectIssueMsg struct{ IssueID string } CreatedMsg struct{ Issue *models.Issue; Err error } DeletedMsg struct{ IssueID string; Err error; PreviousIndex int } @@ -65,6 +66,15 @@ func UpdateIssueTypeCmd(app *app.App, issueID string, issueType models.IssueType } } +// UpdateIssueAssigneeCmd returns a command that updates an issue's assignee. +func UpdateIssueAssigneeCmd(app *app.App, issueID, assignee string) tea.Cmd { + return func() tea.Msg { + updates := map[string]interface{}{"assignee": assignee} + err := app.Issues.UpdateIssue(context.Background(), issueID, updates, "tui") + return AssigneeUpdatedMsg{IssueID: issueID, Err: err} + } +} + // CreateIssueCmd returns a command that creates a new issue. func CreateIssueCmd(app *app.App, title string) tea.Cmd { return func() tea.Msg { diff --git a/pkg/tui/styles/styles.go b/pkg/tui/styles/styles.go index 53b3dfc..73909f3 100644 --- a/pkg/tui/styles/styles.go +++ b/pkg/tui/styles/styles.go @@ -21,7 +21,7 @@ var ( ) const ( - ListViewRatio = 70 // Percentage of total width allocated to the list view + ListViewRatio = 52 // Percentage of total width allocated to the list view LabelWidth = 14 MarginBottomSmall = 1 ) @@ -66,6 +66,8 @@ func StatusStyle(status string) lipgloss.Style { return style.Foreground(FaintText) case "in_progress": return style.Foreground(Warning) + case "blocked": + return style.Foreground(Error) default: return style.Foreground(SecondaryText) } diff --git a/pkg/tui/views/dashboard/keys.go b/pkg/tui/views/dashboard/keys.go index b2d830f..0793cb8 100644 --- a/pkg/tui/views/dashboard/keys.go +++ b/pkg/tui/views/dashboard/keys.go @@ -21,6 +21,7 @@ type DashboardKeyMap struct { ChangeStatus key.Binding ChangePriority key.Binding ChangeType key.Binding + ChangeAssignee key.Binding AddComment key.Binding AddIssue key.Binding DeleteIssue key.Binding @@ -56,6 +57,10 @@ var defaultDashboardKeyMap = DashboardKeyMap{ key.WithKeys("t"), key.WithHelp("t", "change type"), ), + ChangeAssignee: key.NewBinding( + key.WithKeys("A"), + key.WithHelp("A", "change assignee"), + ), AddComment: key.NewBinding( key.WithKeys("c"), key.WithHelp("c", "add comment"), @@ -127,12 +132,18 @@ func (d *Model) handleKeyMsg(msg tea.KeyMsg) tea.Cmd { d.startChooseType(selected) d.logAction("tui opened type picker") } + case !d.IsInModal() && !d.addingComment && key.Matches(msg, d.keyMap.ChangeAssignee): + if selected := d.FocusedIssueList().SelectedItem(); selected.ID != "" { + d.startEditAssignee(selected) + cmd = d.assigneeInput.Focus() + d.logAction("tui started editing assignee") + } case !d.IsInModal() && !d.addingComment && key.Matches(msg, d.keyMap.AddComment): if selected := d.FocusedIssueList().SelectedItem(); selected.ID != "" { d.startAddComment(selected) cmd = d.commentInput.Focus() } - case !d.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && !d.choosingPriority && !d.confirmingDelete && !d.choosingType && !d.addingComment && key.Matches(msg, d.keyMap.AddIssue): + case !d.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && !d.choosingPriority && !d.confirmingDelete && !d.choosingType && !d.editingAssignee && !d.addingComment && key.Matches(msg, d.keyMap.AddIssue): d.startCreateIssue() cmd = d.createTitleInput.Focus() d.logAction("tui started creating issue") diff --git a/pkg/tui/views/dashboard/model.go b/pkg/tui/views/dashboard/model.go index 84962d5..d386143 100644 --- a/pkg/tui/views/dashboard/model.go +++ b/pkg/tui/views/dashboard/model.go @@ -52,6 +52,9 @@ type Model struct { priorityIssueID string choosingType bool // true while choosing a type typeIssueID string + editingAssignee bool // true while editing assignee + assigneeInput textinput.Model + assigneeIssueID string addingComment bool // true while adding a comment commentInput textarea.Model commentIssueID string @@ -87,6 +90,7 @@ func NewDashboard(app *app.App, feedbackChan chan models.ValidationFeedback, qui m.titleInput = inputs.Title m.createTitleInput = inputs.CreateTitle m.descriptionInput = inputs.Description + m.assigneeInput = inputs.Assignee commentTa := textarea.New() commentTa.Placeholder = "Write your comment..." @@ -171,6 +175,13 @@ func (m *Model) startChooseType(selected ListIssue) { m.typeIssueID = selected.ID } +func (m *Model) startEditAssignee(selected ListIssue) { + m.editingAssignee = true + m.assigneeIssueID = selected.ID + m.assigneeInput.SetValue(selected.Assignee) + m.assigneeInput.CursorEnd() +} + func (m *Model) Init() tea.Cmd { return components.ListenForValidation(m.feedbackChan) } @@ -178,7 +189,7 @@ func (m *Model) Init() tea.Cmd { // IsInModal returns true when a modal (edit, create, delete confirm, choose status/priority/type) is active. func (m *Model) IsInModal() bool { return m.editingTitle || m.creatingIssue || m.editingDescription || - m.choosingStatus || m.choosingPriority || m.confirmingDelete || m.choosingType + m.choosingStatus || m.choosingPriority || m.confirmingDelete || m.choosingType || m.editingAssignee } func (m *Model) IsFocusedOnList() bool { diff --git a/pkg/tui/views/dashboard/operations.go b/pkg/tui/views/dashboard/operations.go index 305f24f..b3a8405 100644 --- a/pkg/tui/views/dashboard/operations.go +++ b/pkg/tui/views/dashboard/operations.go @@ -211,6 +211,17 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.logAction("tui updated issue type") return m, m.refreshIssueListsAndSelectIssue(msg.IssueID) + case issues.AssigneeUpdatedMsg: + m.editingAssignee = false + m.assigneeIssueID = "" + m.assigneeInput.Blur() + if msg.Err != nil { + m.logAction("tui failed to update issue assignee") + return m, nil + } + m.logAction("tui updated issue assignee") + return m, m.refreshIssueListsAndSelectIssue(msg.IssueID) + case issues.SelectIssueMsg: m.issueList.SelectIssueID(msg.IssueID) m.closedIssueList.SelectIssueID(msg.IssueID) @@ -343,6 +354,18 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.choosingStatus = false m.statusIssueID = "" return m, issues.UpdateIssueStatusCmd(m.app, issueID, string(models.StatusInProgress)) + case "b": + m.logAction("tui selected issue status blocked") + issueID := m.statusIssueID + m.choosingStatus = false + m.statusIssueID = "" + return m, issues.UpdateIssueStatusCmd(m.app, issueID, string(models.StatusBlocked)) + case "r": + m.logAction("tui selected issue status ready_to_sprint") + issueID := m.statusIssueID + m.choosingStatus = false + m.statusIssueID = "" + return m, issues.UpdateIssueStatusCmd(m.app, issueID, string(models.StatusReadyToSprint)) case "c": m.logAction("tui selected issue status closed") issueID := m.statusIssueID @@ -438,6 +461,24 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, cmd } + if m.editingAssignee { + if msg.String() == "enter" { + assignee := m.assigneeInput.Value() + m.logAction("tui submitted assignee edit") + return m, issues.UpdateIssueAssigneeCmd(m.app, m.assigneeIssueID, assignee) + } + if msg.String() == "esc" { + m.logAction("tui canceled assignee edit") + m.editingAssignee = false + m.assigneeIssueID = "" + m.assigneeInput.Blur() + return m, nil + } + var cmd tea.Cmd + m.assigneeInput, cmd = m.assigneeInput.Update(msg) + return m, cmd + } + if m.editingTitle { if msg.String() == "enter" { newTitle := m.titleInput.Value() diff --git a/pkg/tui/views/dashboard/view.go b/pkg/tui/views/dashboard/view.go index b395f51..87daf94 100644 --- a/pkg/tui/views/dashboard/view.go +++ b/pkg/tui/views/dashboard/view.go @@ -61,6 +61,20 @@ func (m *Model) View() string { mainView := lipgloss.JoinVertical(lipgloss.Left, header, content, footer) + if m.editingAssignee { + editBoxWidth := min(60, m.width-4) + m.assigneeInput.Width = editBoxWidth - 2 + editContent := lipgloss.JoinVertical(lipgloss.Left, + styles.LabelStyle.Render("Edit assignee (Enter to save, Esc to cancel):"), + m.assigneeInput.View(), + ) + editBox := styles.ContainerStyle. + Width(editBoxWidth). + BorderForeground(styles.PrimaryBorder). + Render(editContent) + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, editBox) + } + if m.editingTitle { editBoxWidth := min(60, m.width-4) m.titleInput.Width = editBoxWidth - 2 @@ -135,7 +149,7 @@ func (m *Model) View() string { if m.choosingStatus { statusContent := lipgloss.JoinVertical(lipgloss.Left, styles.LabelStyle.Render("Change status for "+m.statusIssueID+":"), - lipgloss.NewStyle().Foreground(styles.FaintText).Render("o = open i = in_progress c = closed"), + lipgloss.NewStyle().Foreground(styles.FaintText).Render("o = open i = in_progress b = blocked r = ready_to_sprint c = closed"), lipgloss.NewStyle().Foreground(styles.FaintText).Render("Esc = cancel"), ) statusBoxWidth := min(50, m.width-4) diff --git a/pkg/tui/views/kanban/keys.go b/pkg/tui/views/kanban/keys.go index bcaa08c..df8345b 100644 --- a/pkg/tui/views/kanban/keys.go +++ b/pkg/tui/views/kanban/keys.go @@ -70,7 +70,7 @@ func (d *Model) handleKeyMsg(msg tea.KeyMsg) tea.Cmd { d.updateDetailFromSelection() } case !d.IsInModal() && key.Matches(msg, d.keyMap.MoveColumnRight): - if d.focusedColumn < 2 { + if d.focusedColumn < 3 { d.focusedColumn++ d.updateDetailFromSelection() } @@ -86,7 +86,7 @@ func (d *Model) handleKeyMsg(msg tea.KeyMsg) tea.Cmd { d.issueDetail.ScrollUp(1) case d.IsFocusedOnDetail() && key.Matches(msg, d.keyMap.ScrollDown): d.issueDetail.ScrollDown(1) - case !d.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && !d.choosingPriority && !d.confirmingDelete && !d.choosingType && key.Matches(msg, d.keyMap.EditTitle): + case !d.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && !d.choosingPriority && !d.confirmingDelete && !d.choosingType && !d.editingAssignee && key.Matches(msg, d.keyMap.EditTitle): if selected := d.FocusedIssueList().SelectedItem(); selected.ID != "" { d.startEditTitle(selected) cmd = d.titleInput.Focus() @@ -108,6 +108,11 @@ func (d *Model) handleKeyMsg(msg tea.KeyMsg) tea.Cmd { if selected := d.FocusedIssueList().SelectedItem(); selected.ID != "" { d.startChooseType(selected) } + case !d.IsInModal() && key.Matches(msg, d.keyMap.ChangeAssignee): + if selected := d.FocusedIssueList().SelectedItem(); selected.ID != "" { + d.startEditAssignee(selected) + cmd = d.assigneeInput.Focus() + } case !d.IsInModal() && key.Matches(msg, d.keyMap.AddIssue): d.startCreateIssue() cmd = d.createTitleInput.Focus() diff --git a/pkg/tui/views/kanban/model.go b/pkg/tui/views/kanban/model.go index 6ead0ec..54244fc 100644 --- a/pkg/tui/views/kanban/model.go +++ b/pkg/tui/views/kanban/model.go @@ -22,6 +22,7 @@ type Model struct { header Header todoList IssueList inProgList IssueList + blockedList IssueList doneList IssueList issueDetail IssueDetail helpBar components.HelpBar @@ -30,7 +31,7 @@ type Model struct { width int height int - focusedColumn int // 0 = To Do, 1 = In Progress, 2 = Done + focusedColumn int // 0 = To Do, 1 = In Progress, 2 = Blocked, 3 = Done focusOnDetail bool // true when detail pane is focused editingTitle bool // true while we are editing a title @@ -53,6 +54,9 @@ type Model struct { priorityIssueID string choosingType bool // true while choosing a type typeIssueID string + editingAssignee bool // true while editing assignee + assigneeInput textinput.Model + assigneeIssueID string feedbackChan chan models.ValidationFeedback quitChan chan bool submitChan chan<- struct{} @@ -77,10 +81,12 @@ func NewDashboard(app *app.App, feedbackChan chan models.ValidationFeedback, qui allIssues, _ := app.Issues.SearchIssues(context.Background(), "", models.IssueFilter{}) todoIssues := components.StatusOnly(allIssues, models.StatusOpen) inProgIssues := components.StatusOnly(allIssues, models.StatusInProgress) + blockedIssues := components.StatusOnly(allIssues, models.StatusBlocked) doneIssues := components.StatusOnly(allIssues, models.StatusClosed) m.todoList = components.NewIssueListFromIssues(app, todoIssues, 0, 0) m.inProgList = components.NewIssueListFromIssues(app, inProgIssues, 0, 0) + m.blockedList = components.NewIssueListFromIssues(app, blockedIssues, 0, 0) m.doneList = components.NewIssueListFromIssues(app, doneIssues, 0, 0) m.issueDetail = components.NewIssueDetail() m.helpBar = components.NewHelpBar(components.ViewKanban) @@ -89,11 +95,14 @@ func NewDashboard(app *app.App, feedbackChan chan models.ValidationFeedback, qui m.titleInput = inputs.Title m.createTitleInput = inputs.CreateTitle m.descriptionInput = inputs.Description + m.assigneeInput = inputs.Assignee if selected := m.todoList.SelectedItem(); selected.ID != "" { m.issueDetail.SetIssue(selected.Issue) } else if selected := m.inProgList.SelectedItem(); selected.ID != "" { m.issueDetail.SetIssue(selected.Issue) + } else if selected := m.blockedList.SelectedItem(); selected.ID != "" { + m.issueDetail.SetIssue(selected.Issue) } else if selected := m.doneList.SelectedItem(); selected.ID != "" { m.issueDetail.SetIssue(selected.Issue) } @@ -142,6 +151,13 @@ func (m *Model) startChooseType(selected ListIssue) { m.typeIssueID = selected.ID } +func (m *Model) startEditAssignee(selected ListIssue) { + m.editingAssignee = true + m.assigneeIssueID = selected.ID + m.assigneeInput.SetValue(selected.Assignee) + m.assigneeInput.CursorEnd() +} + func (m *Model) Init() tea.Cmd { return components.ListenForValidation(m.feedbackChan) } @@ -149,7 +165,7 @@ func (m *Model) Init() tea.Cmd { // IsInModal returns true when a modal (edit, create, delete confirm, choose status/priority/type) is active. func (m *Model) IsInModal() bool { return m.editingTitle || m.creatingIssue || m.editingDescription || - m.choosingStatus || m.choosingPriority || m.confirmingDelete || m.choosingType + m.choosingStatus || m.choosingPriority || m.confirmingDelete || m.choosingType || m.editingAssignee } func (m *Model) IsFocusedOnList() bool { @@ -185,6 +201,8 @@ func (m *Model) FocusedIssueList() *IssueList { case 1: return &m.inProgList case 2: + return &m.blockedList + case 3: return &m.doneList default: return &m.todoList @@ -208,6 +226,8 @@ func statusForColumn(col int) models.Status { case 1: return models.StatusInProgress case 2: + return models.StatusBlocked + case 3: return models.StatusClosed default: return models.StatusOpen @@ -224,7 +244,7 @@ func (m *Model) moveIssue(delta int) tea.Cmd { } newCol := m.focusedColumn + delta - if newCol < 0 || newCol > 2 { + if newCol < 0 || newCol > 3 { return nil } diff --git a/pkg/tui/views/kanban/operations.go b/pkg/tui/views/kanban/operations.go index 03a6beb..8cfda91 100644 --- a/pkg/tui/views/kanban/operations.go +++ b/pkg/tui/views/kanban/operations.go @@ -21,10 +21,12 @@ func (m *Model) refreshIssueListsAndSelectIssue(issueID string) tea.Cmd { todoIssues := components.StatusOnly(allIssues, models.StatusOpen) inProgIssues := components.StatusOnly(allIssues, models.StatusInProgress) + blockedIssues := components.StatusOnly(allIssues, models.StatusBlocked) doneIssues := components.StatusOnly(allIssues, models.StatusClosed) todoCmd := m.todoList.SetIssues(todoIssues) inProgCmd := m.inProgList.SetIssues(inProgIssues) + blockedCmd := m.blockedList.SetIssues(blockedIssues) doneCmd := m.doneList.SetIssues(doneIssues) var targetStatus models.Status @@ -41,16 +43,19 @@ func (m *Model) refreshIssueListsAndSelectIssue(issueID string) tea.Cmd { m.focusedColumn = 0 case models.StatusInProgress: m.focusedColumn = 1 - case models.StatusClosed: + case models.StatusBlocked: m.focusedColumn = 2 + case models.StatusClosed: + m.focusedColumn = 3 } // Select the moved issue in its new column immediately so the highlight follows it. m.todoList.SelectIssueID(issueID) m.inProgList.SelectIssueID(issueID) + m.blockedList.SelectIssueID(issueID) m.doneList.SelectIssueID(issueID) - return tea.Sequence(todoCmd, inProgCmd, doneCmd) + return tea.Sequence(todoCmd, inProgCmd, blockedCmd, doneCmd) } func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { @@ -97,9 +102,19 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, m.refreshIssueListsAndSelectIssue(msg.IssueID) + case issues.AssigneeUpdatedMsg: + m.editingAssignee = false + m.assigneeIssueID = "" + m.assigneeInput.Blur() + if msg.Err != nil { + return m, nil + } + return m, m.refreshIssueListsAndSelectIssue(msg.IssueID) + case issues.SelectIssueMsg: m.todoList.SelectIssueID(msg.IssueID) m.inProgList.SelectIssueID(msg.IssueID) + m.blockedList.SelectIssueID(msg.IssueID) m.doneList.SelectIssueID(msg.IssueID) return m, nil @@ -117,10 +132,12 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { todoIssues := components.StatusOnly(allIssues, models.StatusOpen) inProgIssues := components.StatusOnly(allIssues, models.StatusInProgress) + blockedIssues := components.StatusOnly(allIssues, models.StatusBlocked) doneIssues := components.StatusOnly(allIssues, models.StatusClosed) todoCmd := m.todoList.SetIssues(todoIssues) inProgCmd := m.inProgList.SetIssues(inProgIssues) + blockedCmd := m.blockedList.SetIssues(blockedIssues) doneCmd := m.doneList.SetIssues(doneIssues) // Determine the created issue from the refreshed list to ensure all fields (like ID) are populated. @@ -136,7 +153,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } m.issueDetail.SetIssue(*selectedIssue) - return m, tea.Sequence(todoCmd, inProgCmd, doneCmd, func() tea.Msg { return issues.SelectIssueMsg{IssueID: selectedIssue.ID} }) + return m, tea.Sequence(todoCmd, inProgCmd, blockedCmd, doneCmd, func() tea.Msg { return issues.SelectIssueMsg{IssueID: selectedIssue.ID} }) case issues.DeletedMsg: m.confirmingDelete = false m.deleteConfirmID = "" @@ -150,16 +167,18 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { todoIssues := components.StatusOnly(allIssues, models.StatusOpen) inProgIssues := components.StatusOnly(allIssues, models.StatusInProgress) + blockedIssues := components.StatusOnly(allIssues, models.StatusBlocked) doneIssues := components.StatusOnly(allIssues, models.StatusClosed) todoCmd := m.todoList.SetIssues(todoIssues) inProgCmd := m.inProgList.SetIssues(inProgIssues) + blockedCmd := m.blockedList.SetIssues(blockedIssues) doneCmd := m.doneList.SetIssues(doneIssues) // If there are no issues at all, clear the detail view and return. - if len(todoIssues) == 0 && len(inProgIssues) == 0 && len(doneIssues) == 0 { + if len(todoIssues) == 0 && len(inProgIssues) == 0 && len(blockedIssues) == 0 && len(doneIssues) == 0 { m.issueDetail.SetIssue(models.Issue{}) - return m, tea.Sequence(todoCmd, inProgCmd, doneCmd) + return m, tea.Sequence(todoCmd, inProgCmd, blockedCmd, doneCmd) } // Determine which column to use for the next selection based on the current focus. @@ -171,9 +190,12 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if len(inProgIssues) > 0 { targetIssues = inProgIssues m.focusedColumn = 1 + } else if len(blockedIssues) > 0 { + targetIssues = blockedIssues + m.focusedColumn = 2 } else if len(doneIssues) > 0 { targetIssues = doneIssues - m.focusedColumn = 2 + m.focusedColumn = 3 } } case 1: @@ -182,13 +204,16 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if len(todoIssues) > 0 { targetIssues = todoIssues m.focusedColumn = 0 + } else if len(blockedIssues) > 0 { + targetIssues = blockedIssues + m.focusedColumn = 2 } else if len(doneIssues) > 0 { targetIssues = doneIssues - m.focusedColumn = 2 + m.focusedColumn = 3 } } case 2: - targetIssues = doneIssues + targetIssues = blockedIssues if len(targetIssues) == 0 { if len(inProgIssues) > 0 { targetIssues = inProgIssues @@ -196,6 +221,23 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } else if len(todoIssues) > 0 { targetIssues = todoIssues m.focusedColumn = 0 + } else if len(doneIssues) > 0 { + targetIssues = doneIssues + m.focusedColumn = 3 + } + } + case 3: + targetIssues = doneIssues + if len(targetIssues) == 0 { + if len(blockedIssues) > 0 { + targetIssues = blockedIssues + m.focusedColumn = 2 + } else if len(inProgIssues) > 0 { + targetIssues = inProgIssues + m.focusedColumn = 1 + } else if len(todoIssues) > 0 { + targetIssues = todoIssues + m.focusedColumn = 0 } } } @@ -203,7 +245,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Safety: if targetIssues is still empty here, just clear detail and return. if len(targetIssues) == 0 { m.issueDetail.SetIssue(models.Issue{}) - return m, tea.Sequence(todoCmd, inProgCmd, doneCmd) + return m, tea.Sequence(todoCmd, inProgCmd, blockedCmd, doneCmd) } newIndex := msg.PreviousIndex @@ -212,7 +254,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } selectedIssue := targetIssues[newIndex] m.issueDetail.SetIssue(*selectedIssue) - return m, tea.Sequence(todoCmd, inProgCmd, doneCmd, func() tea.Msg { + return m, tea.Sequence(todoCmd, inProgCmd, blockedCmd, doneCmd, func() tea.Msg { return issues.SelectIssueMsg{IssueID: selectedIssue.ID} }) @@ -244,6 +286,16 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.choosingStatus = false m.statusIssueID = "" return m, issues.UpdateIssueStatusCmd(m.app, issueID, string(models.StatusInProgress)) + case "b": + issueID := m.statusIssueID + m.choosingStatus = false + m.statusIssueID = "" + return m, issues.UpdateIssueStatusCmd(m.app, issueID, string(models.StatusBlocked)) + case "r": + issueID := m.statusIssueID + m.choosingStatus = false + m.statusIssueID = "" + return m, issues.UpdateIssueStatusCmd(m.app, issueID, string(models.StatusReadyToSprint)) case "c": issueID := m.statusIssueID m.choosingStatus = false @@ -327,6 +379,22 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, cmd } + if m.editingAssignee { + if msg.String() == "enter" { + assignee := m.assigneeInput.Value() + return m, issues.UpdateIssueAssigneeCmd(m.app, m.assigneeIssueID, assignee) + } + if msg.String() == "esc" { + m.editingAssignee = false + m.assigneeIssueID = "" + m.assigneeInput.Blur() + return m, nil + } + var cmd tea.Cmd + m.assigneeInput, cmd = m.assigneeInput.Update(msg) + return m, cmd + } + if m.editingTitle { if msg.String() == "enter" { newTitle := m.titleInput.Value() diff --git a/pkg/tui/views/kanban/view.go b/pkg/tui/views/kanban/view.go index 34fb0da..e1bd5d1 100644 --- a/pkg/tui/views/kanban/view.go +++ b/pkg/tui/views/kanban/view.go @@ -22,7 +22,7 @@ func (m *Model) View() string { contentHeight := m.height - headerHeight - footerHeight totalContentWidth := m.width - 1 - colWidth := totalContentWidth / 3 + colWidth := totalContentWidth / 4 if colWidth < 20 { colWidth = 20 } @@ -35,18 +35,21 @@ func (m *Model) View() string { m.todoList.SetSize(colWidth, boardHeight-1) m.inProgList.SetSize(colWidth, boardHeight-1) + m.blockedList.SetSize(colWidth, boardHeight-1) m.doneList.SetSize(colWidth, boardHeight-1) // Only highlight the selected row in the focused column. m.todoList.SetHighlightSelected(!m.focusOnDetail && m.focusedColumn == 0) m.inProgList.SetHighlightSelected(!m.focusOnDetail && m.focusedColumn == 1) - m.doneList.SetHighlightSelected(!m.focusOnDetail && m.focusedColumn == 2) + m.blockedList.SetHighlightSelected(!m.focusOnDetail && m.focusedColumn == 2) + m.doneList.SetHighlightSelected(!m.focusOnDetail && m.focusedColumn == 3) // Detail view takes full width below the board. m.issueDetail.SetSize(totalContentWidth, contentHeight-boardHeight) todoLabel := styles.LabelStyle.Render("To Do") inProgLabel := styles.LabelStyle.Render("In Progress") + blockedLabel := styles.LabelStyle.Render("Blocked") doneLabel := styles.LabelStyle.Render("Done") highlight := lipgloss.NewStyle().Foreground(styles.Primary).Bold(true) @@ -56,14 +59,17 @@ func (m *Model) View() string { case 1: inProgLabel = highlight.Render("In Progress ▶") case 2: + blockedLabel = highlight.Render("Blocked ▶") + case 3: doneLabel = highlight.Render("Done ▶") } todoCol := lipgloss.JoinVertical(lipgloss.Left, todoLabel, m.todoList.View()) inProgCol := lipgloss.JoinVertical(lipgloss.Left, inProgLabel, m.inProgList.View()) + blockedCol := lipgloss.JoinVertical(lipgloss.Left, blockedLabel, m.blockedList.View()) doneCol := lipgloss.JoinVertical(lipgloss.Left, doneLabel, m.doneList.View()) - board := lipgloss.JoinHorizontal(lipgloss.Left, todoCol, inProgCol, doneCol) + board := lipgloss.JoinHorizontal(lipgloss.Left, todoCol, inProgCol, blockedCol, doneCol) content := lipgloss.JoinVertical(lipgloss.Left, board, m.issueDetail.View()) // Add spacer to lock footer to bottom of screen when content is shorter than available space @@ -93,6 +99,8 @@ func (m *Model) View() string { m.priorityIssueID, m.choosingType, m.typeIssueID, + m.editingAssignee, + m.assigneeInput.View(), mainView, ) diff --git a/pkg/web/assets/js/board-drag-drop.js b/pkg/web/assets/js/board-drag-drop.js new file mode 100644 index 0000000..fba3bad --- /dev/null +++ b/pkg/web/assets/js/board-drag-drop.js @@ -0,0 +1,68 @@ +/** + * Drag and drop for board view - allows moving issue cards between status columns + */ +(function () { + document.addEventListener("dragstart", function (e) { + if (e.target.closest("button") || e.target.closest("a")) return; + const card = e.target.closest(".board-card"); + if (!card) return; + e.dataTransfer.setData("text/plain", card.dataset.issueId); + e.dataTransfer.effectAllowed = "move"; + card.classList.add("opacity-50"); + }); + + document.addEventListener("dragend", function (e) { + const card = e.target.closest(".board-card"); + if (card) card.classList.remove("opacity-50"); + }); + + document.addEventListener("dragover", function (e) { + const zone = e.target.closest(".column-drop-zone"); + if (!zone) return; + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + zone.classList.add("ring-2", "ring-primary", "ring-inset"); + }); + + document.addEventListener("dragleave", function (e) { + const zone = e.target.closest(".column-drop-zone"); + if (!zone || zone.contains(e.relatedTarget)) return; + zone.classList.remove("ring-2", "ring-primary", "ring-inset"); + }); + + document.addEventListener("drop", function (e) { + const zone = e.target.closest(".column-drop-zone"); + if (!zone) return; + e.preventDefault(); + zone.classList.remove("ring-2", "ring-primary", "ring-inset"); + const issueId = e.dataTransfer.getData("text/plain"); + const newStatus = zone.dataset.status; + if (!issueId || !newStatus) return; + + const formData = new URLSearchParams(); + formData.append("status", newStatus); + + fetch("/issues/" + issueId + "?from=board", { + method: "PATCH", + body: formData, + headers: { + "Content-Type": "application/x-www-form-urlencoded", + "HX-Request": "true", + }, + }).then(function (r) { + if (r.ok) { + var redirect = r.headers.get("HX-Redirect"); + if (redirect) { + window.location.href = redirect; + } else if (typeof htmx !== "undefined") { + htmx.ajax("GET", "/?board=true", { + target: "main", + swap: "innerHTML", + }); + } else { + window.location.href = "/?board=true"; + } + } + }); + }); +})(); diff --git a/pkg/web/components/assignee_form_templ.go b/pkg/web/components/assignee_form_templ.go index 06f3ea1..ae68a4f 100644 --- a/pkg/web/components/assignee_form_templ.go +++ b/pkg/web/components/assignee_form_templ.go @@ -1,6 +1,6 @@ // Code generated by templ - DO NOT EDIT. -// templ: version: v0.3.977 +// templ: version: v0.3.1001 package components //lint:file-ignore SA4006 This context is only used if a nested component is present. diff --git a/pkg/web/components/base/input_templ.go b/pkg/web/components/base/input_templ.go index 9d787ce..1b68a0c 100644 --- a/pkg/web/components/base/input_templ.go +++ b/pkg/web/components/base/input_templ.go @@ -1,6 +1,6 @@ // Code generated by templ - DO NOT EDIT. -// templ: version: v0.3.977 +// templ: version: v0.3.1001 package base //lint:file-ignore SA4006 This context is only used if a nested component is present. diff --git a/pkg/web/components/base/range_templ.go b/pkg/web/components/base/range_templ.go index 4ae6395..1a57ebe 100644 --- a/pkg/web/components/base/range_templ.go +++ b/pkg/web/components/base/range_templ.go @@ -1,6 +1,6 @@ // Code generated by templ - DO NOT EDIT. -// templ: version: v0.3.977 +// templ: version: v0.3.1001 package base //lint:file-ignore SA4006 This context is only used if a nested component is present. diff --git a/pkg/web/components/base/select_templ.go b/pkg/web/components/base/select_templ.go index aab85ee..a7b5040 100644 --- a/pkg/web/components/base/select_templ.go +++ b/pkg/web/components/base/select_templ.go @@ -1,6 +1,6 @@ // Code generated by templ - DO NOT EDIT. -// templ: version: v0.3.977 +// templ: version: v0.3.1001 package base //lint:file-ignore SA4006 This context is only used if a nested component is present. diff --git a/pkg/web/components/base/table_templ.go b/pkg/web/components/base/table_templ.go index b7544a0..7259528 100644 --- a/pkg/web/components/base/table_templ.go +++ b/pkg/web/components/base/table_templ.go @@ -1,6 +1,6 @@ // Code generated by templ - DO NOT EDIT. -// templ: version: v0.3.977 +// templ: version: v0.3.1001 package base //lint:file-ignore SA4006 This context is only used if a nested component is present. diff --git a/pkg/web/components/base/textarea_templ.go b/pkg/web/components/base/textarea_templ.go index 32f9357..0d1bf07 100644 --- a/pkg/web/components/base/textarea_templ.go +++ b/pkg/web/components/base/textarea_templ.go @@ -1,6 +1,6 @@ // Code generated by templ - DO NOT EDIT. -// templ: version: v0.3.977 +// templ: version: v0.3.1001 package base //lint:file-ignore SA4006 This context is only used if a nested component is present. diff --git a/pkg/web/components/close_issue_form.templ b/pkg/web/components/close_issue_form.templ new file mode 100644 index 0000000..7c6a1c2 --- /dev/null +++ b/pkg/web/components/close_issue_form.templ @@ -0,0 +1,34 @@ +package components + +import "github.com/LazyBachelor/LazyPM/pkg/web/components/base" + +type CloseIssueFormProps struct { + PostAction string +} + +templ CloseIssueForm(props CloseIssueFormProps) { +
+ +} diff --git a/pkg/web/components/close_issue_form_templ.go b/pkg/web/components/close_issue_form_templ.go new file mode 100644 index 0000000..1e2b421 --- /dev/null +++ b/pkg/web/components/close_issue_form_templ.go @@ -0,0 +1,79 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.1001 +package components + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +import "github.com/LazyBachelor/LazyPM/pkg/web/components/base" + +type CloseIssueFormProps struct { + PostAction string +} + +func CloseIssueForm(props CloseIssueFormProps) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/pkg/web/components/comment_templ.go b/pkg/web/components/comment_templ.go index e97390e..2542d51 100644 --- a/pkg/web/components/comment_templ.go +++ b/pkg/web/components/comment_templ.go @@ -1,6 +1,6 @@ // Code generated by templ - DO NOT EDIT. -// templ: version: v0.3.977 +// templ: version: v0.3.1001 package components //lint:file-ignore SA4006 This context is only used if a nested component is present. diff --git a/pkg/web/components/header_templ.go b/pkg/web/components/header_templ.go index 4a6a7fa..a4c3217 100644 --- a/pkg/web/components/header_templ.go +++ b/pkg/web/components/header_templ.go @@ -1,6 +1,6 @@ // Code generated by templ - DO NOT EDIT. -// templ: version: v0.3.977 +// templ: version: v0.3.1001 package components //lint:file-ignore SA4006 This context is only used if a nested component is present. diff --git a/pkg/web/components/icons_templ.go b/pkg/web/components/icons_templ.go index c8c421c..0df923b 100644 --- a/pkg/web/components/icons_templ.go +++ b/pkg/web/components/icons_templ.go @@ -1,6 +1,6 @@ // Code generated by templ - DO NOT EDIT. -// templ: version: v0.3.977 +// templ: version: v0.3.1001 package components //lint:file-ignore SA4006 This context is only used if a nested component is present. diff --git a/pkg/web/components/issue.templ b/pkg/web/components/issue.templ index 9ce4408..714f5c9 100644 --- a/pkg/web/components/issue.templ +++ b/pkg/web/components/issue.templ @@ -7,20 +7,22 @@ import ( ) type IssueFormProps struct { - PostAction string - PatchAction string - Title string - Description string - Status string - Priority int - IssueType string - Class string - Attrs templ.Attributes - Target string // Optional: if empty, server controls via HX-Target header + PostAction string + PatchAction string + DeleteAction string // Optional: URL for delete confirmation modal + Title string + Description string + Status string + CloseReason string // Set when status is "closed" + Priority int + IssueType string + Class string + Attrs templ.Attributes + Target string // Optional: if empty, server controls via HX-Target header } templ IssueForm(props IssueFormProps) { -{ props.Issue.CreatedBy }
if props.Issue.Assignee == "" { - Unassigned + Unassigned (click to assign) } else { - Assignee: -{ props.Issue.Assignee }
+ Assignee: { props.Issue.Assignee } } @@ -61,6 +60,8 @@ templ StatusBadge(status models.Status) { Open case "in_progress": In Progress + case "ready_to_sprint": + Ready to sprint case "closed": Closed case "blocked": diff --git a/pkg/web/components/issue_detail_templ.go b/pkg/web/components/issue_detail_templ.go index 63f34ac..c405ee5 100644 --- a/pkg/web/components/issue_detail_templ.go +++ b/pkg/web/components/issue_detail_templ.go @@ -1,6 +1,6 @@ // Code generated by templ - DO NOT EDIT. -// templ: version: v0.3.977 +// templ: version: v0.3.1001 package components //lint:file-ignore SA4006 This context is only used if a nested component is present. @@ -130,7 +130,7 @@ func IssueDetail(props IssueDetailProps) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "Unassigned") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "Unassigned (click to assign) ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "Assignee:") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "Assignee: ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var8 string templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(props.Issue.Assignee) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/components/issue_detail.templ`, Line: 52, Col: 56} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/components/issue_detail.templ`, Line: 51, Col: 42} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -211,36 +211,41 @@ func StatusBadge(status models.Status) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } + case "ready_to_sprint": + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "Ready to sprint") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } case "closed": - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "Closed") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "Closed") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } case "blocked": - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "Blocked") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "Blocked") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } case "deferred": - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "Deferred") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "Deferred") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } default: - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var10 string templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(string(status)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/components/issue_detail.templ`, Line: 71, Col: 60} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/components/issue_detail.templ`, Line: 72, Col: 60} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -272,45 +277,45 @@ func TypeBadge(issueType models.IssueType) templ.Component { ctx = templ.ClearChildren(ctx) switch issueType { case "bug": - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "Bug") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "Bug") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } case "feature": - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "Feature") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "Feature") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } case "task": - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "Task") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "Task") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } case "chore": - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "Chore") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "Chore") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } case "epic": - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "Epic") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "Epic") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } default: - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var12 string templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(string(issueType)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/components/issue_detail.templ`, Line: 88, Col: 65} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/components/issue_detail.templ`, Line: 89, Col: 65} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -342,45 +347,45 @@ func PriorityBadge(priority int) templ.Component { ctx = templ.ClearChildren(ctx) switch priority { case 0: - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "Irrelevant") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "Irrelevant") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } case 1: - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "Low") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "Low") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } case 2: - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "Normal") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "Normal") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } case 3: - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "High") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "High") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } case 4: - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "Critical") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "Critical") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } default: - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var14 string templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("P%d", priority)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/components/issue_detail.templ`, Line: 105, Col: 74} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/components/issue_detail.templ`, Line: 106, Col: 74} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/pkg/web/components/issue_templ.go b/pkg/web/components/issue_templ.go index 7715cb9..bf4c931 100644 --- a/pkg/web/components/issue_templ.go +++ b/pkg/web/components/issue_templ.go @@ -1,6 +1,6 @@ // Code generated by templ - DO NOT EDIT. -// templ: version: v0.3.977 +// templ: version: v0.3.1001 package components //lint:file-ignore SA4006 This context is only used if a nested component is present. @@ -15,16 +15,18 @@ import ( ) type IssueFormProps struct { - PostAction string - PatchAction string - Title string - Description string - Status string - Priority int - IssueType string - Class string - Attrs templ.Attributes - Target string // Optional: if empty, server controls via HX-Target header + PostAction string + PatchAction string + DeleteAction string // Optional: URL for delete confirmation modal + Title string + Description string + Status string + CloseReason string // Set when status is "closed" + Priority int + IssueType string + Class string + Attrs templ.Attributes + Target string // Optional: if empty, server controls via HX-Target header } func IssueForm(props IssueFormProps) templ.Component { @@ -66,68 +68,81 @@ func IssueForm(props IssueFormProps) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "\">") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - if props.Target == "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "") + if props.DeleteAction != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "") + if props.Target == "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -237,9 +295,9 @@ func IssueTable(props IssueTableProps) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var7 := templ.GetChildren(ctx) - if templ_7745c5c3_Var7 == nil { - templ_7745c5c3_Var7 = templ.NopComponent + templ_7745c5c3_Var9 := templ.GetChildren(ctx) + if templ_7745c5c3_Var9 == nil { + templ_7745c5c3_Var9 = templ.NopComponent } ctx = templ.ClearChildren(ctx) templ_7745c5c3_Err = base.Table( @@ -279,117 +337,112 @@ func IssueRows(issues []*models.Issue) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var8 := templ.GetChildren(ctx) - if templ_7745c5c3_Var8 == nil { - templ_7745c5c3_Var8 = templ.NopComponent + templ_7745c5c3_Var10 := templ.GetChildren(ctx) + if templ_7745c5c3_Var10 == nil { + templ_7745c5c3_Var10 = templ.NopComponent } ctx = templ.ClearChildren(ctx) for _, issue := range issues { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "{ props.Message }
+") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var12 string + templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(props.Message) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/components/modal.templ`, Line: 78, Col: 52} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "
`+feedback.Message+`
`) + io.WriteString(w, `You can close the browser and return to the terminal window
`) + } + for _, check := range feedback.Checks { if !check.Valid { io.WriteString(w, ``+"❌ "+check.Message+`
`) diff --git a/pkg/web/input.css b/pkg/web/input.css index 73bfd82..1341d1f 100644 --- a/pkg/web/input.css +++ b/pkg/web/input.css @@ -1,4 +1,10 @@ @import 'tailwindcss'; + +/* Alpine.js x-cloak: hide until Alpine initializes */ +[x-cloak] { + display: none !important; +} + @source "./**/*.templ"; @source "./**/*.go"; @source "./components/**/*.templ"; diff --git a/pkg/web/routes/boardview.templ b/pkg/web/routes/boardview.templ new file mode 100644 index 0000000..e2924f9 --- /dev/null +++ b/pkg/web/routes/boardview.templ @@ -0,0 +1,155 @@ +package routes + +import ( + "fmt" + "github.com/LazyBachelor/LazyPM/internal/models" + "github.com/LazyBachelor/LazyPM/pkg/web/components" +) + +type BoardViewProps struct { + Issues []*models.Issue + BaseURL string + QueryParam string + EmptyText string +} + +templ BoardView(props BoardViewProps) { + @BaseLayout() { + @BoardViewContent(props) + } +} + +templ BoardViewContent(props BoardViewProps) { +{ issue.Description }
+ } +") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var18 string + templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Description) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/routes/boardview.templ`, Line: 151, Col: 81} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "