Merge branch 'main' into Metrics-and-Logs

This commit is contained in:
Robin Olsen
2026-03-04 14:32:54 +01:00
9 changed files with 231 additions and 36 deletions

View File

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

View File

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

View File

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

View File

@@ -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"),

View File

@@ -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()

View File

@@ -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())

View File

@@ -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())

View File

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

View File

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