diff --git a/cmd/pm/tasks/createIssue.go b/cmd/pm/tasks/createIssue.go index d931b0a..4dde38b 100644 --- a/cmd/pm/tasks/createIssue.go +++ b/cmd/pm/tasks/createIssue.go @@ -4,6 +4,7 @@ import ( "context" "fmt" + "github.com/LazyBachelor/LazyPM/internal/models" "github.com/LazyBachelor/LazyPM/internal/utils/check" ) @@ -12,10 +13,10 @@ const description = `You are tasked with creating a new issue in the project man This task will test your ability to use the issue creation workflow effectively. Your task: -1. Create a new issue with a clear title -2. Add a detailed description explaining what needs to be done -3. Assign the issue to yourself -4. Mark the issue as in-progress when you start working on it +1. Create a new issue with the title "My first Issue" +2. Add this detailed description "I need to do some coding" +3. Assign the issue to yourself as "Me" +4. Mark the issue as In Progress when you start working on it 5. Close the issue once you've completed the work Make sure to fill out all the necessary details to help others understand the work item.` @@ -60,6 +61,8 @@ func (t *CreateIssueTask) Setup(ctx context.Context) error { return t.app.Issues.CreateIssue(ctx, t.setupIssue, "") } +var isInProgress = false + func (t *CreateIssueTask) Validate(ctx context.Context) ValidationFeedback { expect := check.NewExpector() @@ -68,9 +71,6 @@ func (t *CreateIssueTask) Validate(ctx context.Context) ValidationFeedback { return expect.ValidationFeedback } - expect.NotEmptyString(t.setupIssue.Assignee, - fmt.Sprintf("%s is not assigned to anyone", t.setupIssue.ID)) - if len(issues) == 0 { expect.Fail("No new issues created") return expect.ValidationFeedback @@ -78,8 +78,30 @@ func (t *CreateIssueTask) Validate(ctx context.Context) ValidationFeedback { issue := issues[0] - expect.Assert(len(issues) < 2, "Multiple issues were created instead of one") + expect.Assert(len(issues) < 2, "Multiple issues were created instead of one. Delete the extra issues and try again.") + + expect.NotEmptyString(issue.Title, "Issue title should not be empty") + expect.Assert(issue.Title == "My first Issue", + fmt.Sprintf("Issue title does not match the expected value 'My first Issue', but was '%s'", issue.Title)) + expect.NotEmptyString(issue.Description, "Issue description should not be empty") + expect.Assert(issue.Description == "I need to do some coding", + fmt.Sprintf("Issue description does not match the expected value 'I need to do some coding', but was '%s'", issue.Description)) + + expect.Assert(issue.Assignee == "Me", + fmt.Sprintf("Issue should be assigned to 'Me', but was assigned to '%s'", issue.Assignee)) + + if issue.Status == models.StatusInProgress || isInProgress { + isInProgress = true + } else { + expect.Fail("Issue should be marked as in-progress when work starts") + } + + if !isInProgress { + return expect.ValidationFeedback + } else if issue.Status != models.StatusClosed { + expect.Fail("Issue should be set to Closed once the work is completed") + } return expect.Complete() } diff --git a/internal/utils/check/expect.go b/internal/utils/check/expect.go index cfa40a2..670fcdb 100644 --- a/internal/utils/check/expect.go +++ b/internal/utils/check/expect.go @@ -35,6 +35,14 @@ func (e *Expector) Complete() ValidationFeedback { return e.ValidationFeedback } +func (e *Expector) CompleteWithMessage(message string) ValidationFeedback { + e.Success = len(e.Errors()) == 0 + if !e.Success { + e.Message = message + } + return e.ValidationFeedback +} + func (e *Expector) Fail(message string) ValidationFeedback { e.Checks = append(e.Checks, NewCheck(message, false)) return e.ValidationFeedback diff --git a/pkg/task/runner.go b/pkg/task/runner.go index 4685a5f..005a03f 100644 --- a/pkg/task/runner.go +++ b/pkg/task/runner.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "log/slog" + "time" "github.com/LazyBachelor/LazyPM/internal/models" tea "github.com/charmbracelet/bubbletea" @@ -194,3 +195,30 @@ func runQuestionnaire(t Tasker, iType InterfaceType, collector *taskRunCollector return nil } + +func startValidationLoop(ctx context.Context, t Tasker, feedbackChan chan ValidationFeedback, doneChan chan bool, quitChan chan bool) { + ticker := time.NewTicker(1 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + feedback := t.Validate(ctx) + if feedback.Success { + if feedback.Message == "" { + feedback.Message = "Task completed successfully! Going back to the survey menu..." + } + feedbackChan <- feedback + time.Sleep(4 * time.Second) + doneChan <- true + return + } + feedback.Message = "Task not completed!" + feedbackChan <- feedback + case <-quitChan: + return + case <-ctx.Done(): + return + } + } +} diff --git a/pkg/tui/views/dashboard/help_bar.go b/pkg/tui/views/dashboard/help_bar.go index 3ad63cc..d5b7891 100644 --- a/pkg/tui/views/dashboard/help_bar.go +++ b/pkg/tui/views/dashboard/help_bar.go @@ -36,7 +36,7 @@ func (h HelpBar) shortHelp() string { styles.HighlightKey("↓/j") + " down", styles.HighlightKey("pgup/pgdn") + " page", styles.HighlightKey("a") + " add", - styles.HighlightKey("e/d/s/p/t") + " edit", + styles.HighlightKey("e/d/s/p/t/c") + " edit", styles.HighlightKey("x") + " delete", styles.HighlightKey("q") + " quit", styles.HighlightKey("?") + " help", @@ -75,6 +75,7 @@ func (h HelpBar) fullHelp() string { renderRow("pgup", "page up", "pgdn", "page down"), renderRow("b", "back to list", "a", "add issue"), renderRow("e", "edit title", "d", "edit desc"), + renderRow("c", "add comment", "", ""), renderRow("s", "change status", "p", "change priority"), renderRow("t", "change type", "x", "delete issue"), renderRow("?", "help", "q", "quit"), diff --git a/pkg/tui/views/dashboard/issue_detail.go b/pkg/tui/views/dashboard/issue_detail.go index 8681800..2899bca 100644 --- a/pkg/tui/views/dashboard/issue_detail.go +++ b/pkg/tui/views/dashboard/issue_detail.go @@ -1,6 +1,8 @@ package dashboard import ( + "time" + "github.com/LazyBachelor/LazyPM/internal/models" "github.com/LazyBachelor/LazyPM/pkg/tui/styles" "github.com/charmbracelet/bubbles/viewport" @@ -10,6 +12,7 @@ import ( type IssueDetail struct { viewport viewport.Model issue models.Issue + comments []*models.Comment focused bool } @@ -25,6 +28,12 @@ func (i *IssueDetail) SetIssue(issue models.Issue) { i.refreshContent() } +// SetComments updates the list of comments displayed for the current issue. +func (i *IssueDetail) SetComments(comments []*models.Comment) { + i.comments = comments + i.refreshContent() +} + func (i *IssueDetail) SetSize(width, height int) { i.viewport.Height = height i.viewport.Width = width @@ -60,19 +69,34 @@ func (i *IssueDetail) refreshContent() { descLabel := styles.LabelStyle.Render("Description:") descContent := styles.ValueStyle.Render(i.issue.Description) - content := lipgloss.JoinVertical(lipgloss.Left, - titleRow, - idRow, - typeRow, - statusRow, - priorityRow, - descLabel, - descContent, - ) + var parts []string + parts = append(parts, titleRow, idRow, typeRow, statusRow, priorityRow, descLabel, descContent) + // Comments section + commentsLabel := styles.LabelStyle.Render("Comments:") + parts = append(parts, commentsLabel) + if len(i.comments) == 0 { + parts = append(parts, lipgloss.NewStyle().Foreground(styles.FaintText).Render(" No comments yet.")) + } else { + for _, c := range i.comments { + authorDate := lipgloss.NewStyle().Foreground(styles.Primary).Render(c.Author) + " " + + lipgloss.NewStyle().Foreground(styles.FaintText).Render(formatCommentTime(c.CreatedAt)) + commentRow := lipgloss.JoinVertical(lipgloss.Left, + authorDate, + styles.ValueStyle.Render(c.Text), + ) + parts = append(parts, commentRow) + } + } + + content := lipgloss.JoinVertical(lipgloss.Left, parts...) i.viewport.SetContent(content) } +func formatCommentTime(t time.Time) string { + return t.Format("Jan 2, 15:04") +} + func (i IssueDetail) View() string { content := i.viewport.View() diff --git a/pkg/tui/views/dashboard/keys.go b/pkg/tui/views/dashboard/keys.go index 557a64e..ac02aa8 100644 --- a/pkg/tui/views/dashboard/keys.go +++ b/pkg/tui/views/dashboard/keys.go @@ -18,6 +18,7 @@ type DashboardKeyMap struct { ChangeStatus key.Binding ChangePriority key.Binding ChangeType key.Binding + AddComment key.Binding AddIssue key.Binding DeleteIssue key.Binding } @@ -71,6 +72,10 @@ var defaultDashboardKeyMap = DashboardKeyMap{ key.WithKeys("t"), key.WithHelp("t", "change type"), ), + AddComment: key.NewBinding( + key.WithKeys("c"), + key.WithHelp("c", "add comment"), + ), AddIssue: key.NewBinding( key.WithKeys("a"), key.WithHelp("a", "add issue"), @@ -106,38 +111,43 @@ func (d *Model) handleKeyMsg(msg tea.KeyMsg) tea.Cmd { case d.IsFocusedOnDetail() && key.Matches(msg, d.keyMap.ScrollDown): d.issueDetail.ScrollDown(1) d.logAction("tui scrolled issue detail down") - 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.addingComment && key.Matches(msg, d.keyMap.EditTitle): if selected := d.FocusedIssueList().SelectedItem(); selected.ID != "" { d.startEditTitle(selected) cmd = d.titleInput.Focus() d.logAction("tui started editing issue title") } - case !d.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && !d.choosingPriority && !d.confirmingDelete && !d.choosingType && key.Matches(msg, d.keyMap.EditDescription): + case !d.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && !d.choosingPriority && !d.confirmingDelete && !d.choosingType && !d.addingComment && key.Matches(msg, d.keyMap.EditDescription): if selected := d.FocusedIssueList().SelectedItem(); selected.ID != "" { d.startEditDescription(selected) cmd = d.descriptionInput.Focus() d.logAction("tui started editing issue description") } - case !d.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && !d.choosingPriority && !d.confirmingDelete && !d.choosingType && key.Matches(msg, d.keyMap.ChangeStatus): + case !d.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && !d.choosingPriority && !d.confirmingDelete && !d.choosingType && !d.addingComment && key.Matches(msg, d.keyMap.ChangeStatus): if selected := d.FocusedIssueList().SelectedItem(); selected.ID != "" { d.startChooseStatus(selected) d.logAction("tui opened status picker") } - case !d.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && !d.choosingPriority && !d.confirmingDelete && !d.choosingType && key.Matches(msg, d.keyMap.ChangePriority): + case !d.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && !d.choosingPriority && !d.confirmingDelete && !d.choosingType && !d.addingComment && key.Matches(msg, d.keyMap.ChangePriority): if selected := d.FocusedIssueList().SelectedItem(); selected.ID != "" { d.startChoosePriority(selected) d.logAction("tui opened priority picker") } - case !d.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && !d.choosingPriority && !d.confirmingDelete && !d.choosingType && key.Matches(msg, d.keyMap.ChangeType): + case !d.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && !d.choosingPriority && !d.confirmingDelete && !d.choosingType && !d.addingComment && key.Matches(msg, d.keyMap.ChangeType): if selected := d.FocusedIssueList().SelectedItem(); selected.ID != "" { d.startChooseType(selected) d.logAction("tui opened type picker") } - case !d.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && !d.choosingPriority && !d.confirmingDelete && !d.choosingType && key.Matches(msg, d.keyMap.AddIssue): + case !d.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && !d.choosingPriority && !d.confirmingDelete && !d.choosingType && !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): d.startCreateIssue() cmd = d.createTitleInput.Focus() d.logAction("tui started creating issue") - case !d.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && !d.choosingPriority && !d.confirmingDelete && !d.choosingType && key.Matches(msg, d.keyMap.DeleteIssue): + case !d.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && !d.choosingPriority && !d.confirmingDelete && !d.choosingType && !d.addingComment && key.Matches(msg, d.keyMap.DeleteIssue): fl := d.FocusedIssueList() if selected := fl.SelectedItem(); selected.ID != "" { d.startConfirmDelete(selected.ID, fl.Index()) diff --git a/pkg/tui/views/dashboard/model.go b/pkg/tui/views/dashboard/model.go index 4b98ec0..ae1fba5 100644 --- a/pkg/tui/views/dashboard/model.go +++ b/pkg/tui/views/dashboard/model.go @@ -47,6 +47,9 @@ type Model struct { priorityIssueID string choosingType bool // true while choosing a type typeIssueID string + addingComment bool // true while adding a comment + commentInput textarea.Model + commentIssueID string feedbackChan chan models.ValidationFeedback quitChan chan bool currentFeedback models.ValidationFeedback @@ -89,15 +92,39 @@ func NewDashboard(app *app.App, feedbackChan chan models.ValidationFeedback, qui descTa.SetHeight(8) m.descriptionInput = descTa + commentTa := textarea.New() + commentTa.Placeholder = "Write your comment..." + commentTa.SetWidth(56) + commentTa.SetHeight(6) + m.commentInput = commentTa + if selected := m.issueList.SelectedItem(); selected.ID != "" { - m.issueDetail.SetIssue(selected.Issue) + m.setDetailIssueWithComments(selected.Issue) } else if selected := m.closedIssueList.SelectedItem(); selected.ID != "" { - m.issueDetail.SetIssue(selected.Issue) + m.setDetailIssueWithComments(selected.Issue) } return m } +// setDetailIssueWithComments sets the issue in the detail pane and loads its comments. +func (m *Model) setDetailIssueWithComments(issue models.Issue) { + m.issueDetail.SetIssue(issue) + if issue.ID == "" { + m.issueDetail.SetComments(nil) + return + } + comments, _ := m.app.Issues.GetIssueComments(context.Background(), issue.ID) + m.issueDetail.SetComments(comments) +} + +func (m *Model) startAddComment(selected ListIssue) { + m.addingComment = true + m.commentIssueID = selected.ID + m.commentInput.SetValue("") + m.commentInput.Reset() +} + func (m *Model) logAction(action string) { if m.app != nil { m.app.LogAction(models.EncodeActionEvent(models.ActionEvent{ @@ -212,11 +239,11 @@ func (m *Model) ToggleFocusedWindow() { m.focusedWindow = 1 - m.focusedWindow if m.focusedWindow == 0 { if selected := m.issueList.SelectedItem(); selected.ID != "" { - m.issueDetail.SetIssue(selected.Issue) + m.setDetailIssueWithComments(selected.Issue) } } else { if selected := m.closedIssueList.SelectedItem(); selected.ID != "" { - m.issueDetail.SetIssue(selected.Issue) + m.setDetailIssueWithComments(selected.Issue) } } m.issueDetail.SetFocused(m.IsFocusedOnDetail()) diff --git a/pkg/tui/views/dashboard/operations.go b/pkg/tui/views/dashboard/operations.go index 6a5a038..566692e 100644 --- a/pkg/tui/views/dashboard/operations.go +++ b/pkg/tui/views/dashboard/operations.go @@ -2,6 +2,8 @@ package dashboard import ( "context" + "os" + "os/user" "github.com/LazyBachelor/LazyPM/internal/app" "github.com/LazyBachelor/LazyPM/internal/models" @@ -9,6 +11,19 @@ import ( tea "github.com/charmbracelet/bubbletea" ) +func defaultCommentAuthor() string { + if u, err := user.Current(); err == nil && u.Username != "" { + return u.Username + } + if s := os.Getenv("USER"); s != "" { + return s + } + if s := os.Getenv("USERNAME"); s != "" { + return s + } + return "user" +} + type issueTitleUpdatedMsg struct { IssueID string Err error @@ -49,6 +64,18 @@ type issueDeletedMsg struct { PreviousIndex int } +type issueCommentAddedMsg struct { + IssueID string + Err error +} + +func addIssueCommentCmd(app *app.App, issueID, author, text string) tea.Cmd { + return func() tea.Msg { + _, err := app.Issues.AddIssueComment(context.Background(), issueID, author, text) + return issueCommentAddedMsg{IssueID: issueID, Err: err} + } +} + func updateIssueTitleCmd(app *app.App, issueID, newTitle string) tea.Cmd { return func() tea.Msg { updates := map[string]interface{}{"title": newTitle} @@ -121,7 +148,7 @@ func (m *Model) refreshIssueListsAndSelectIssue(issueID string) tea.Cmd { closedSetCmd := m.closedIssueList.SetIssues(ClosedOnly(issues)) for _, issue := range issues { if issue.ID == issueID { - m.issueDetail.SetIssue(*issue) + m.setDetailIssueWithComments(*issue) break } } @@ -214,9 +241,18 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } - m.issueDetail.SetIssue(*selectedIssue) + m.setDetailIssueWithComments(*selectedIssue) m.logAction("tui created issue") return m, tea.Sequence(setItemsCmd, closedSetCmd, func() tea.Msg { return selectIssueMsg{IssueID: selectedIssue.ID} }) + case issueCommentAddedMsg: + m.addingComment = false + m.commentIssueID = "" + m.commentInput.Blur() + m.commentInput.Reset() + if msg.Err != nil { + return m, nil + } + return m, m.refreshIssueListsAndSelectIssue(msg.IssueID) case issueDeletedMsg: m.confirmingDelete = false m.deleteConfirmID = "" @@ -235,7 +271,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { closedSetCmd := m.closedIssueList.SetIssues(closedIssues) // If there are no issues at all, clear the detail view and return. if len(openIssues) == 0 && len(closedIssues) == 0 { - m.issueDetail.SetIssue(models.Issue{}) + m.setDetailIssueWithComments(models.Issue{}) return m, tea.Sequence(setItemsCmd, closedSetCmd) } @@ -259,7 +295,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{}) + m.setDetailIssueWithComments(models.Issue{}) return m, tea.Sequence(setItemsCmd, closedSetCmd) } @@ -268,7 +304,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { newIndex = len(targetIssues) - 1 } selectedIssue := targetIssues[newIndex] - m.issueDetail.SetIssue(*selectedIssue) + m.setDetailIssueWithComments(*selectedIssue) return m, tea.Sequence(setItemsCmd, closedSetCmd, func() tea.Msg { return selectIssueMsg{IssueID: selectedIssue.ID} }) @@ -420,6 +456,30 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, cmd } + if m.addingComment { + if msg.String() == "ctrl+s" || msg.String() == "enter" { + text := m.commentInput.Value() + if text != "" { + issueID := m.commentIssueID + m.addingComment = false + m.commentIssueID = "" + m.commentInput.Blur() + m.commentInput.Reset() + return m, addIssueCommentCmd(m.app, issueID, defaultCommentAuthor(), text) + } + } + if msg.String() == "esc" { + m.addingComment = false + m.commentIssueID = "" + m.commentInput.Blur() + m.commentInput.Reset() + return m, nil + } + var cmd tea.Cmd + m.commentInput, cmd = m.commentInput.Update(msg) + return m, cmd + } + if m.editingDescription { if msg.String() == "ctrl+s" { m.logAction("tui submitted issue description edit") @@ -475,7 +535,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmd, changed := m.issueList.Update(msg) if changed { if selected := m.issueList.SelectedItem(); selected.ID != "" { - m.issueDetail.SetIssue(selected.Issue) + m.setDetailIssueWithComments(selected.Issue) } } return m, cmd @@ -483,7 +543,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmd, changed := m.closedIssueList.Update(msg) if changed { if selected := m.closedIssueList.SelectedItem(); selected.ID != "" { - m.issueDetail.SetIssue(selected.Issue) + m.setDetailIssueWithComments(selected.Issue) } } return m, cmd diff --git a/pkg/tui/views/dashboard/view.go b/pkg/tui/views/dashboard/view.go index 2df8781..74aca51 100644 --- a/pkg/tui/views/dashboard/view.go +++ b/pkg/tui/views/dashboard/view.go @@ -70,6 +70,21 @@ func (m *Model) View() string { return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, editBox) } + if m.addingComment { + editBoxWidth := min(60, m.width-4) + m.commentInput.SetWidth(editBoxWidth - 2) + m.commentInput.SetHeight(8) + editContent := lipgloss.JoinVertical(lipgloss.Left, + styles.LabelStyle.Render("Add comment for "+m.commentIssueID+" (Ctrl+S or Enter to save, Esc to cancel):"), + m.commentInput.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.editingDescription { editBoxWidth := min(60, m.width-4) m.descriptionInput.SetWidth(editBoxWidth - 2)