diff --git a/Makefile b/Makefile index a864b41..8ebd04b 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,13 @@ SHELL := /bin/bash +ifneq (,$(wildcard .env)) + include .env + export +endif + +LDFLAGS := \ + -X 'main.DB_URI=$(DB_URI)' + tidy: go mod tidy @@ -16,7 +24,7 @@ clean: @rm -f ./build/pm_bash.sh build: tidy - @go build -o ./bin/pm ./cmd/pm + @go build -ldflags "$(LDFLAGS)" -o ./bin/pm ./cmd/pm @go build -o ./bin/tui ./cmd/tui @go build -o ./bin/web ./cmd/web @echo "Build completed successfully. Binaries are located in the ./bin directory." @@ -36,7 +44,7 @@ docker-run: docker-push: @docker push telikz/lazypm:latest -os-build: build completions +os-build: completions build @cp ./bin/pm ./build/pm @cp ./bin/pm_bash.sh ./build/pm_bash.sh @docker build -t telikz/lazyos ./build diff --git a/build/Dockerfile b/build/Dockerfile index 795657e..8cfc7fa 100644 --- a/build/Dockerfile +++ b/build/Dockerfile @@ -1,6 +1,14 @@ FROM lscr.io/linuxserver/webtop:latest -RUN apk add --no-cache exo gcompat libc6-compat bash bash-completion alacritty +RUN apk add --no-cache \ + ca-certificates \ + exo \ + gcompat \ + libc6-compat \ + bash \ + bash-completion \ + alacritty && \ + update-ca-certificates COPY pm /usr/local/bin/pm COPY pm_bash.sh /etc/bash_completion.d/pm diff --git a/cmd/pm/init.go b/cmd/pm/init.go index d55afde..dd456c8 100644 --- a/cmd/pm/init.go +++ b/cmd/pm/init.go @@ -22,6 +22,10 @@ func init() { models.BaseConfig = models.BaseConfig.LoadFromEnv() + if DB_URI != "" { + models.BaseConfig = models.BaseConfig.WithDbUri(DB_URI) + } + task.RegisterInterface("tui", tui.New()) task.RegisterInterface("web", web.New()) task.RegisterInterface("repl", repl.New()) @@ -41,6 +45,9 @@ func init() { task.RegisterTask("priority_management", func(app *app.App) task.Tasker { return tasks.NewPriorityManagementTask(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) }) 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/main.go b/cmd/pm/main.go index 4b74d3a..d6bd53c 100644 --- a/cmd/pm/main.go +++ b/cmd/pm/main.go @@ -11,6 +11,7 @@ import ( var App *app.App var appCleanup func() var RootCmd = issues.RootCmd +var DB_URI string func main() { ctx := context.Background() diff --git a/cmd/pm/runner.go b/cmd/pm/runner.go index c9def4a..98c69d3 100644 --- a/cmd/pm/runner.go +++ b/cmd/pm/runner.go @@ -129,7 +129,19 @@ func runStartCmd(cmd *cobra.Command, args []string) error { 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 { return returnIfUserQuit(err, "task loop failed") } diff --git a/cmd/pm/tasks/dependencyManagement.go b/cmd/pm/tasks/dependencyManagement.go new file mode 100644 index 0000000..f4b521f --- /dev/null +++ b/cmd/pm/tasks/dependencyManagement.go @@ -0,0 +1,161 @@ +package tasks + +import ( + "context" + "fmt" + + "github.com/LazyBachelor/LazyPM/internal/models" + "github.com/LazyBachelor/LazyPM/internal/utils/check" + "github.com/charmbracelet/huh" +) + +const dependencyManagementDescription = `You are tasked with managing issue dependencies. + +Several issues in your project have dependencies on other issues. You need to: + +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.` + +type DependencyManagementTask struct { + done bool + app *App + setupIssue *Issue + depIssues []*Issue +} + +func NewDependencyManagementTask(app *App) *DependencyManagementTask { + return &DependencyManagementTask{app: app, done: false} +} + +func (t *DependencyManagementTask) Config() Config { + return BaseConfig().WithStatisticsStoragePath("./.pm/dependency-task-stats.json") +} + +func (t *DependencyManagementTask) Details(interfaceType InterfaceType) TaskDetails { + return BaseDetails(interfaceType). + WithTitle("Dependency Management Task"). + WithDescription(dependencyManagementDescription). + WithTimeToComplete("15m"). + WithDifficulty("Hard") +} + +func (t *DependencyManagementTask) Questions(interfaceType InterfaceType) Questions { + return BaseQuestions(interfaceType).With( + huh.NewGroup( + huh.NewSelect[int](). + Title("How many foundational issues did you identify?"). + Options( + huh.NewOption("1", 1), + huh.NewOption("2", 2), + huh.NewOption("3+", 3), + ), + ), + ) +} + +func (t *DependencyManagementTask) Setup(ctx context.Context) error { + if err := ClearIssues(t.app); err != nil { + return err + } + + t.depIssues = []*Issue{ + NewIssueBuilder(). + WithTitle("Setup database connection"). + WithDescription("Configure database connection pool."). + WithPriority(2). + WithStatus(models.StatusOpen). + WithIssueType(models.TypeTask). + Build(), + NewIssueBuilder(). + 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 Authentication System"). + WithDescription("Add login/logout functionality. Depends on 'Setup database connection' issue."). + WithPriority(2). + WithStatus(models.StatusOpen). + WithIssueType(models.TypeTask). + Build(), + NewIssueBuilder(). + WithTitle("Add user management operations"). + WithDescription("Add operations for user management. Depends on 'Setup database connection' issue."). + WithPriority(3). + 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, t.depIssues, ""); err != nil { + return err + } + + t.setupIssue = NewIssueBuilder(). + WithTitle("Dependency Management"). + WithDescription(dependencyManagementDescription). + Build() + + return t.app.Issues.CreateIssue(ctx, t.setupIssue, "") +} + +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/issueReviewCleanup.go b/cmd/pm/tasks/issueReviewCleanup.go new file mode 100644 index 0000000..a609e1d --- /dev/null +++ b/cmd/pm/tasks/issueReviewCleanup.go @@ -0,0 +1,136 @@ +package tasks + +import ( + "context" + + "github.com/LazyBachelor/LazyPM/internal/models" + "github.com/LazyBachelor/LazyPM/internal/utils/check" +) + +const issueReviewCleanupDescription = `You are responsible for reviewing and maintaining the current project issues. + +Using the system, complete the following steps: + +1. Open three different issues and read their titles and descriptions +2. Add a comment to two issues +3. Delete this cleanup task issue ("Issue Review and Cleanup Task") from the issue list — do not delete the other project issues +4. Confirm that the cleanup task issue no longer appears in the list` + +type IssueReviewCleanupTask struct { + done bool + app *App + setupIssue *models.Issue +} + +func NewIssueReviewCleanupTask(app *App) *IssueReviewCleanupTask { + return &IssueReviewCleanupTask{app: app, done: false} +} + +func (t *IssueReviewCleanupTask) Config() Config { + return BaseConfig().WithStatisticsStoragePath("./.pm/issue-review-cleanup-stats.json") +} + +func (t *IssueReviewCleanupTask) Details(interfaceType InterfaceType) TaskDetails { + return BaseDetails(interfaceType). + WithTitle("Issue Review and Cleanup Task"). + WithDescription(issueReviewCleanupDescription). + WithTimeToComplete("8m"). + WithDifficulty("Easy") +} + +func (t *IssueReviewCleanupTask) Questions(interfaceType InterfaceType) Questions { + return BaseQuestions(interfaceType) +} + +func (t *IssueReviewCleanupTask) Setup(ctx context.Context) error { + if err := ClearIssues(t.app); err != nil { + return err + } + + reviewIssues := []*models.Issue{ + models.NewIssueBuilder(). + WithTitle("Fix login page layout"). + WithDescription("The login form is misaligned on smaller screens. Needs responsive CSS adjustments."). + WithPriority(2). + WithStatus(models.StatusOpen). + WithIssueType(models.TypeTask). + Build(), + models.NewIssueBuilder(). + WithTitle("Add unit tests for API endpoints"). + WithDescription("Current coverage is low. Add tests for /users and /projects endpoints."). + WithPriority(1). + WithStatus(models.StatusOpen). + WithIssueType(models.TypeTask). + Build(), + models.NewIssueBuilder(). + WithTitle("Update dependencies"). + WithDescription("Several npm packages have security advisories. Run npm audit and update."). + WithPriority(2). + WithStatus(models.StatusOpen). + WithIssueType(models.TypeTask). + Build(), + models.NewIssueBuilder(). + WithTitle("Improve error messages"). + WithDescription("Form validation errors are generic. Make them more helpful for users."). + WithPriority(3). + WithStatus(models.StatusOpen). + WithIssueType(models.TypeTask). + Build(), + models.NewIssueBuilder(). + WithTitle("Document deployment process"). + WithDescription("Add README section for deploying to production environment."). + WithPriority(3). + WithStatus(models.StatusOpen). + WithIssueType(models.TypeTask). + Build(), + } + + if err := t.app.Issues.CreateIssues(ctx, reviewIssues, ""); err != nil { + return err + } + + t.setupIssue = models.NewBaseIssue(). + WithTitle("Issue Review and Cleanup Task"). + WithDescription(issueReviewCleanupDescription). + Build() + + return t.app.Issues.CreateIssue(ctx, t.setupIssue, "") +} + +func (t *IssueReviewCleanupTask) Validate(ctx context.Context) ValidationFeedback { + expect := check.NewExpector() + + issues, err := FetchIssues(ctx, t.app, t.setupIssue) + if err != nil { + return expect.Fatal("Could not fetch issues") + } + + // Count the amount of comments on each issue + commentCount := map[string]int{} + for _, issue := range issues { + comments, _ := t.app.Issues.GetIssueComments(ctx, issue.ID) + commentCount[issue.ID] = len(comments) + } + + // Add a count if there are comment on a issue + commentsInIssues := 0 + for _, count := range commentCount { + if count > 0 { + commentsInIssues++ + } + } + + expect.Equal(commentsInIssues, 2, "Comments on issues") + + // Fetching the setup issue as as FetchIssues only updates the setup issue if it exists, + // we can check if it was deleted by seeing if it can be fetched again + if t.setupIssue, err = t.app.Issues.GetIssue(ctx, t.setupIssue.ID); t.setupIssue != nil { + expect.Fail("Setup issue still exists") + } else { + expect.Pass("Setup issue deleted") + } + + expect.Equal(len(issues), 5, "Number of remaining issues") + + return expect.Complete() +} diff --git a/cmd/pm/tasks/priorityManagement.go b/cmd/pm/tasks/priorityManagement.go index e405204..a541ccc 100644 --- a/cmd/pm/tasks/priorityManagement.go +++ b/cmd/pm/tasks/priorityManagement.go @@ -19,7 +19,9 @@ 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).` +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 diff --git a/internal/app/statistics.go b/internal/app/statistics.go index cdf27e4..abd12a6 100644 --- a/internal/app/statistics.go +++ b/internal/app/statistics.go @@ -131,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/completion.go b/internal/commands/issues/completion.go index 19c8a35..1ab4670 100644 --- a/internal/commands/issues/completion.go +++ b/internal/commands/issues/completion.go @@ -11,7 +11,7 @@ import ( // Variables for completion options and functions. var ( typeOptions = []string{"bug", "feature", "task", "chore"} - statusOptions = []string{"open", "closed", "in_progress", "ready_to_sprint"} + statusOptions = []string{"open", "closed", "in_progress", "blocked", "ready_to_sprint"} priorityRange = []string{"0", "1", "2", "3", "4"} ) diff --git a/internal/commands/survey/submit.go b/internal/commands/survey/submit.go index bcdd77a..74d5b1b 100644 --- a/internal/commands/survey/submit.go +++ b/internal/commands/survey/submit.go @@ -22,6 +22,10 @@ var SubmitCmd = &cobra.Command{ return nil } + if !cmd.Flags().Changed("dev") { + fmt.Println("DBUri", app.Config.DbUri) + } + db, err := storage.NewMongoStorageInteractive(cmd.Context(), app.Config.DbUri) if err != nil { return fmt.Errorf("failed to connect to database: %w", err) @@ -37,3 +41,7 @@ var SubmitCmd = &cobra.Command{ return nil }, } + +func init() { + SubmitCmd.Flags().Bool("dev", false, "Show DB connection details for development purposes") +} diff --git a/internal/models/app.go b/internal/models/app.go index 9b81226..e1e5603 100644 --- a/internal/models/app.go +++ b/internal/models/app.go @@ -61,4 +61,5 @@ type StatsService interface { GetStatistics() (Statistics, error) GetParticipantID() primitive.ObjectID RecordTaskRun(ctx context.Context, run TaskRunMetrics) error + RecordIntroQuestionnaireAnswers(answers map[string]any) error } diff --git a/internal/models/statistics.go b/internal/models/statistics.go index 41041ac..fe4c8df 100644 --- a/internal/models/statistics.go +++ b/internal/models/statistics.go @@ -20,7 +20,10 @@ type Statistics struct { 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"` + TotalUserActions int `bson:"total_user_actions" json:"total_user_actions"` + + IntroQuestionnaireAnswers map[string]any `bson:"intro_questionnaire_answers" json:"intro_questionnaire_answers"` + QuestionnairesCompleted int `bson:"questionnaires_completed" json:"questionnaires_completed"` QuestionnairesAbandoned int `bson:"questionnaires_abandoned" json:"questionnaires_abandoned"` diff --git a/internal/utils/check/expect.go b/internal/utils/check/expect.go index dbeaa29..256c6e6 100644 --- a/internal/utils/check/expect.go +++ b/internal/utils/check/expect.go @@ -90,9 +90,10 @@ func (e *Expector) Equal(val, expected any, message string) *Expector { } func (e *Expector) Assert(condition bool, message string) *Expector { - check := NewCheck(message, condition) - e.Checks = append(e.Checks, check) - return e + if !condition { + return e.Fail(message) + } + return e.Pass(message + " is correct") } func (e *Expector) Nil(value any, message string) *Expector { diff --git a/pkg/repl/suggestions.go b/pkg/repl/suggestions.go index 8785944..4824a66 100644 --- a/pkg/repl/suggestions.go +++ b/pkg/repl/suggestions.go @@ -79,6 +79,7 @@ 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"}, } diff --git a/pkg/tui/components/issue_list.go b/pkg/tui/components/issue_list.go index ff920ef..da81d08 100644 --- a/pkg/tui/components/issue_list.go +++ b/pkg/tui/components/issue_list.go @@ -230,6 +230,7 @@ func OpenAndInProgressOnly(issues []*models.Issue) []*models.Issue { for _, issue := range issues { if issue.Status == models.StatusOpen || issue.Status == models.StatusInProgress || + issue.Status == models.StatusBlocked || issue.Status == models.StatusReadyToSprint { out = append(out, issue) } diff --git a/pkg/tui/components/modals.go b/pkg/tui/components/modals.go index c6f2e21..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 ready_to_sprint = r"), + 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) diff --git a/pkg/tui/styles/styles.go b/pkg/tui/styles/styles.go index 076b214..73909f3 100644 --- a/pkg/tui/styles/styles.go +++ b/pkg/tui/styles/styles.go @@ -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/operations.go b/pkg/tui/views/dashboard/operations.go index 4487d39..306d009 100644 --- a/pkg/tui/views/dashboard/operations.go +++ b/pkg/tui/views/dashboard/operations.go @@ -354,6 +354,12 @@ 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 diff --git a/pkg/tui/views/kanban/keys.go b/pkg/tui/views/kanban/keys.go index db11527..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() } diff --git a/pkg/tui/views/kanban/model.go b/pkg/tui/views/kanban/model.go index 581ddfe..5a38984 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 @@ -84,10 +85,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) @@ -108,6 +111,8 @@ func NewDashboard(app *app.App, feedbackChan chan models.ValidationFeedback, qui 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) } @@ -208,6 +213,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 @@ -231,6 +238,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 @@ -247,7 +256,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 91fbba0..db09d9f 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) { @@ -109,6 +114,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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 @@ -126,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. @@ -145,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 = "" @@ -159,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. @@ -180,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: @@ -191,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 @@ -205,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 } } } @@ -212,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 @@ -221,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} }) @@ -253,6 +286,11 @@ 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 diff --git a/pkg/tui/views/kanban/view.go b/pkg/tui/views/kanban/view.go index 1ae1065..8e6a279 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 diff --git a/pkg/web/components/issue.templ b/pkg/web/components/issue.templ index faedc3c..714f5c9 100644 --- a/pkg/web/components/issue.templ +++ b/pkg/web/components/issue.templ @@ -57,6 +57,7 @@ templ IssueForm(props IssueFormProps) { Options: []base.SelectOption{ {Label: "Open", Value: "open", Selected: props.Status == "open"}, {Label: "In Progress", Value: "in_progress", Selected: props.Status == "in_progress"}, + {Label: "Blocked", Value: "blocked", Selected: props.Status == "blocked"}, {Label: "Ready to sprint", Value: "ready_to_sprint", Selected: props.Status == "ready_to_sprint"}, {Label: "Closed", Value: "closed", Selected: props.Status == "closed"}, }, diff --git a/pkg/web/components/issue_templ.go b/pkg/web/components/issue_templ.go index 1e57c18..bf4c931 100644 --- a/pkg/web/components/issue_templ.go +++ b/pkg/web/components/issue_templ.go @@ -180,6 +180,7 @@ func IssueForm(props IssueFormProps) templ.Component { Options: []base.SelectOption{ {Label: "Open", Value: "open", Selected: props.Status == "open"}, {Label: "In Progress", Value: "in_progress", Selected: props.Status == "in_progress"}, + {Label: "Blocked", Value: "blocked", Selected: props.Status == "blocked"}, {Label: "Ready to sprint", Value: "ready_to_sprint", Selected: props.Status == "ready_to_sprint"}, {Label: "Closed", Value: "closed", Selected: props.Status == "closed"}, }, @@ -249,7 +250,7 @@ func IssueForm(props IssueFormProps) templ.Component { var templ_7745c5c3_Var8 string templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(props.DeleteAction) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/components/issue.templ`, Line: 108, Col: 31} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/components/issue.templ`, Line: 109, Col: 31} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) if templ_7745c5c3_Err != nil { @@ -349,7 +350,7 @@ func IssueRows(issues []*models.Issue) templ.Component { var templ_7745c5c3_Var11 string templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs("/issues/" + issue.ID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/components/issue.templ`, Line: 148, Col: 35} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/components/issue.templ`, Line: 149, Col: 35} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) if templ_7745c5c3_Err != nil { @@ -362,7 +363,7 @@ func IssueRows(issues []*models.Issue) templ.Component { var templ_7745c5c3_Var12 string templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(issue.ID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/components/issue.templ`, Line: 152, Col: 15} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/components/issue.templ`, Line: 153, Col: 15} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) if templ_7745c5c3_Err != nil { @@ -375,7 +376,7 @@ func IssueRows(issues []*models.Issue) templ.Component { var templ_7745c5c3_Var13 string templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs("/issues/" + issue.ID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/components/issue.templ`, Line: 157, Col: 35} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/components/issue.templ`, Line: 158, Col: 35} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) if templ_7745c5c3_Err != nil { @@ -388,7 +389,7 @@ func IssueRows(issues []*models.Issue) templ.Component { var templ_7745c5c3_Var14 string templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/components/issue.templ`, Line: 161, Col: 18} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/components/issue.templ`, Line: 162, Col: 18} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) if templ_7745c5c3_Err != nil { @@ -409,7 +410,7 @@ func IssueRows(issues []*models.Issue) templ.Component { var templ_7745c5c3_Var15 string templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(string(issue.IssueType)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/components/issue.templ`, Line: 166, Col: 57} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/components/issue.templ`, Line: 167, Col: 57} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) if templ_7745c5c3_Err != nil { @@ -422,7 +423,7 @@ func IssueRows(issues []*models.Issue) templ.Component { var templ_7745c5c3_Var16 string templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("P%d", issue.Priority)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/components/issue.templ`, Line: 167, Col: 68} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/components/issue.templ`, Line: 168, Col: 68} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) if templ_7745c5c3_Err != nil { @@ -435,7 +436,7 @@ func IssueRows(issues []*models.Issue) templ.Component { var templ_7745c5c3_Var17 string templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs("/issues/" + issue.ID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/components/issue.templ`, Line: 172, Col: 38} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/components/issue.templ`, Line: 173, Col: 38} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) if templ_7745c5c3_Err != nil { diff --git a/pkg/web/handler/issues.go b/pkg/web/handler/issues.go index aad3f63..3f48bc1 100644 --- a/pkg/web/handler/issues.go +++ b/pkg/web/handler/issues.go @@ -17,7 +17,7 @@ const commentsKey = "comments" type IssueForm struct { Title string `form:"title" validate:"required,max=255"` Description string `form:"description" validate:"max=2000"` - Status models.Status `form:"status" validate:"required,oneof=open in_progress ready_to_sprint closed"` + Status models.Status `form:"status" validate:"required,oneof=open in_progress blocked ready_to_sprint closed"` IssueType models.IssueType `form:"issue_type" validate:"required,oneof=task bug feature chore"` Priority int `form:"priority" validate:"gte=0,lte=4"` } @@ -25,7 +25,7 @@ type IssueForm struct { type UpdateIssueForm struct { Title *string `form:"title" validate:"omitempty,max=255"` Description *string `form:"description" validate:"omitempty,max=2000"` - Status *models.Status `form:"status" validate:"omitempty,oneof=open in_progress ready_to_sprint closed"` + Status *models.Status `form:"status" validate:"omitempty,oneof=open in_progress blocked ready_to_sprint closed"` CloseReason *string `form:"close_reason" validate:"omitempty,max=2000"` IssueType *models.IssueType `form:"issue_type" validate:"omitempty,oneof=task bug feature chore"` Priority *int `form:"priority" validate:"omitempty,gte=0,lte=4"` diff --git a/pkg/web/routes/boardview.templ b/pkg/web/routes/boardview.templ index e926309..e2924f9 100644 --- a/pkg/web/routes/boardview.templ +++ b/pkg/web/routes/boardview.templ @@ -62,6 +62,7 @@ templ BoardColumns(issues []*models.Issue) { {{ openIssues := []*models.Issue{} inProgressIssues := []*models.Issue{} + blockedIssues := []*models.Issue{} readyToSprintIssues := []*models.Issue{} closedIssues := []*models.Issue{} @@ -71,6 +72,8 @@ templ BoardColumns(issues []*models.Issue) { openIssues = append(openIssues, issue) case "in_progress": inProgressIssues = append(inProgressIssues, issue) + case "blocked": + blockedIssues = append(blockedIssues, issue) case "ready_to_sprint": readyToSprintIssues = append(readyToSprintIssues, issue) case "closed": @@ -81,6 +84,7 @@ templ BoardColumns(issues []*models.Issue) {