diff --git a/cmd/pm/init.go b/cmd/pm/init.go index 322a2b2..5ba5040 100644 --- a/cmd/pm/init.go +++ b/cmd/pm/init.go @@ -6,8 +6,8 @@ import ( "github.com/LazyBachelor/LazyPM/cmd/pm/tasks" "github.com/LazyBachelor/LazyPM/internal/app" - issues "github.com/LazyBachelor/LazyPM/internal/commands/issues" - survey "github.com/LazyBachelor/LazyPM/internal/commands/survey" + "github.com/LazyBachelor/LazyPM/internal/commands/issues" + "github.com/LazyBachelor/LazyPM/internal/commands/survey" "github.com/LazyBachelor/LazyPM/pkg/repl" "github.com/LazyBachelor/LazyPM/pkg/task" "github.com/LazyBachelor/LazyPM/pkg/tui" @@ -149,7 +149,7 @@ func setupLazyInitialization() { func commandNeedsApp(cmd *cobra.Command) bool { name := cmd.Name() switch name { - case "help", "completion", "__complete", "__completeNoDesc": + case "help", "completion": return false } diff --git a/cmd/pm/runner.go b/cmd/pm/runner.go index 3da601e..1c14bae 100644 --- a/cmd/pm/runner.go +++ b/cmd/pm/runner.go @@ -7,8 +7,7 @@ import ( "math/rand" "github.com/LazyBachelor/LazyPM/cmd/pm/tasks" - survey "github.com/LazyBachelor/LazyPM/internal/commands/survey" - "github.com/LazyBachelor/LazyPM/internal/models" + "github.com/LazyBachelor/LazyPM/internal/commands/survey" "github.com/LazyBachelor/LazyPM/pkg/task" "github.com/spf13/cobra" ) @@ -55,13 +54,13 @@ func runStartCmd(cmd *cobra.Command, args []string) error { return returnIfUserQuit(err, "failed to run intro") } - if err := taskLoop(cmd.Context(), surveyTasks, interfaces); err != nil { + if err := taskLoop(cmd.Context(), app, surveyTasks, interfaces); err != nil { return returnIfUserQuit(err, "task loop failed") } return nil } -func taskLoop(ctx context.Context, surveyTasks map[string]task.Tasker, interfaces map[string]task.Interface) error { +func taskLoop(ctx context.Context, application *task.App, surveyTasks map[string]task.Tasker, interfaces map[string]task.Interface) error { var iNames []string for name := range interfaces { iNames = append(iNames, name) @@ -84,7 +83,9 @@ func taskLoop(ctx context.Context, surveyTasks map[string]task.Tasker, interface iIdx := idx % len(iNames) selected := interfaces[iNames[iIdx]] - if err := task.RunTask(ctx, t, selected, tasks.InterfaceToType(selected)); err != nil { + runner := task.NewTaskRunner(application) + + if err := runner.Run(ctx, t, selected, tasks.InterfaceToType(selected)); err != nil { return err } idx++ @@ -93,7 +94,7 @@ func taskLoop(ctx context.Context, surveyTasks map[string]task.Tasker, interface } func returnIfUserQuit(err error, msg string) error { - if errors.Is(err, models.ErrUserQuit) { + if errors.Is(err, task.ErrUserQuit) { return nil } return fmt.Errorf("%s: %w", msg, err) diff --git a/cmd/pm/tasks/base.go b/cmd/pm/tasks/base.go index a00122b..ca78479 100644 --- a/cmd/pm/tasks/base.go +++ b/cmd/pm/tasks/base.go @@ -73,10 +73,12 @@ func BaseQuestions(interfaceType InterfaceType) Questions { return Questions{ huh.NewGroup( huh.NewConfirm(). + Key("task_completed"). Title("Did you complete the task?"), ), huh.NewGroup( huh.NewSelect[int]().Value(&taskRating). + Key("task_difficulty"). Options( huh.NewOption("Very easy", 1), huh.NewOption("Easy", 2), @@ -115,13 +117,13 @@ func TUIQuestion(interfaceType InterfaceType, fields ...huh.Field) *huh.Group { // FetchIssues retrieves all issues from the app and returns those that are relevant for validation, // excluding the setup issue. It also updates the setup issue with the latest data from the app. -func FetchIssues(ctx context.Context, app *app.App, setupIssue *models.Issue) ([]*models.Issue, error) { +func FetchIssues(ctx context.Context, app *App, setupIssue *Issue) ([]*Issue, error) { issues, err := app.Issues.SearchIssues(ctx, "", models.IssueFilter{}) if err != nil { return nil, err } - var relevantIssues []*models.Issue + var relevantIssues []*Issue for _, issue := range issues { if issue.ID != setupIssue.ID { relevantIssues = append(relevantIssues, issue) diff --git a/cmd/pm/tasks/codingTask.go b/cmd/pm/tasks/codingTask.go index 24165f0..754f94c 100644 --- a/cmd/pm/tasks/codingTask.go +++ b/cmd/pm/tasks/codingTask.go @@ -3,36 +3,77 @@ package tasks import ( "context" "os" + "strings" + "github.com/LazyBachelor/LazyPM/internal/models" "github.com/LazyBachelor/LazyPM/internal/utils/check" - "github.com/charmbracelet/huh" ) -const codingDescription = `You are tasked with writing a simple function. +const codingDescription = `You are tasked with doing a chore in the codebase. -This task will test your ability to write clean, working code. +This task will test your ability to read and understand instructions, change text, and save it to a file. + +The MongoDB Driver dependency in the file is outdated and needs to be updated to the latest version. +This is a common task for developers, and it requires attention to detail and the ability to follow instructions carefully. Your task: -1. Review the requirements below -2. Write a function that takes two integers and returns their sum -3. The function should be named "Add" -4. The function should be part of the "coding" package -5. Save your code to the code.txt file +1. Create a New Issue and give it these details: + - Title: "Upgrade MongoDB Driver Dependency" + - Description: "We need to upgrade the MongoDB Driver dependency to the latest version." + - Status: "In Progress" + - Issue Type: "Chore" +2. A file will appear in the current directory named "code.txt". + Open it and follow the instructions inside. And save the file after you are done. +3. When you are done, mark this and the issue you made as "Closed".` -Requirements: -- Function name: Add -- Parameters: two integers -- Return value: integer (sum of the two inputs) -- Package: coding` +var textFileDescription = ` +Please upgrade the MongoDB Driver dependency in the go.mod file to the latest version. +It should be v1.17.9. After you are done, save the file and mark the task as completed. +############################################################` -var textFileContent = codingDescription + ` -Please write your code below this line! -############################################################ +var code = ` +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/muesli/reflow v0.3.0 + github.com/steveyegge/beads v0.49.6 + go.mongodb.org/mongo-driver v1.17.8 + go.mongodb.org/mongo-driver/v2 v2.5.0 +) + +require ( + github.com/c-bata/go-prompt v0.2.6 + github.com/charmbracelet/bubbles v0.21.1 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/fang v0.4.4 + github.com/charmbracelet/huh v0.8.0 + github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 + github.com/spf13/cobra v1.10.2 + golang.org/x/term v0.40.0 +) + +require ( + github.com/NYTimes/gziphandler v1.1.1 + github.com/a-h/templ v0.3.977 + 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 + github.com/go-playground/validator/v10 v10.30.1 + github.com/rs/cors v1.11.1 +) + +tool ( + github.com/a-h/templ/cmd/templ + github.com/haatos/goshipit/cmd/gsi +) ` +var textFileContent = codingDescription + textFileDescription + "\n" + code + type CodingTask struct { - done bool - app *App + done bool + setupIssue *Issue + app *App } func NewCodingTask(app *App) *CodingTask { @@ -48,23 +89,12 @@ func (t *CodingTask) Details() TaskDetails { } func (t *CodingTask) Questions(interfaceType InterfaceType) (questions Questions) { - return BaseQuestions(interfaceType). - With( - ReplQuestion(interfaceType, - huh.NewConfirm().Title("Question only for REPL interface")), - ). - With( - WebQuestion(interfaceType, - huh.NewInput().Title("Question only for Web interface")), - ). - With( - TUIQuestion(interfaceType, - huh.NewConfirm().Title("Question only for TUI interface")), - ). - With( - Question( - huh.NewConfirm().Title("One last question for all interfaces!")), - ) + return BaseQuestions(interfaceType) +} + +func (t *CodingTask) QuestionnaireKeys(interfaceType InterfaceType) []string { + keys := []string{"task_completed", "task_difficulty"} + return keys } func (t *CodingTask) Setup(ctx context.Context) error { @@ -72,6 +102,17 @@ func (t *CodingTask) Setup(ctx context.Context) error { return err } + t.setupIssue = NewIssueBuilder(). + WithTitle("Coding Task - Upgrade MongoDB Driver"). + WithDescription(codingDescription). + WithStatus(models.StatusOpen). + WithIssueType(models.TypeTask). + Build() + + if err := t.app.Issues.CreateIssue(ctx, t.setupIssue, "LazyPM"); err != nil { + return err + } + if err := os.WriteFile("./code.txt", []byte(textFileContent), 0644); err != nil { return err } @@ -79,8 +120,65 @@ func (t *CodingTask) Setup(ctx context.Context) error { return nil } +var codingTaskInProgress = false + func (t *CodingTask) Validate(ctx context.Context) ValidationFeedback { expect := check.NewExpector() - expect.Assert(true, "This task is always valid") + + issues, err := FetchIssues(ctx, t.app, t.setupIssue) + if err != nil { + return expect.ValidationFeedback + } + + if len(issues) == 0 { + expect.Fail("No new issues created") + return expect.ValidationFeedback + } else { + expect.Pass("An issue was created") + } + + issue := issues[0] + + expect.Assert(len(issues) < 2, "Multiple issues were created instead of one") + + expect.NotEmptyAndEqual(issue.Title, "Upgrade MongoDB Driver Dependency", "Issue title") + + expect.NotEmptyAndEqual(issue.Description, + "We need to upgrade the MongoDB Driver dependency to the latest version.", "Issue description") + + expect.NotEmptyAndEqual(issue.Assignee, "Me", "Issue Assignee") + + expect.Equal(issue.IssueType, models.TypeChore, "Issue type") + + if issue.Status == models.StatusInProgress || codingTaskInProgress { + codingTaskInProgress = true + } else { + expect.Fail("The issue should be marked as In Progress while working on the task.") + return expect.ValidationFeedback + } + + if _, err := os.Stat("./code.txt"); os.IsNotExist(err) { + expect.Fail("The code.txt file should exist on the desktop.") + return expect.ValidationFeedback + } + + fileContent, err := os.ReadFile("./code.txt") + if err != nil { + expect.Fail("Error reading code.txt file: " + err.Error()) + return expect.ValidationFeedback + } + + code, ok := strings.CutPrefix(string(fileContent), codingDescription+textFileDescription+"\n") + if !ok { + expect.Fail("The content of code.txt does not match the expected format.") + return expect.ValidationFeedback + } + + expect.Assert(strings.Contains(code, "go.mongodb.org/mongo-driver v1.17.9"), + "The MongoDB Driver dependency should be updated to version v1.17.9 in the file.") + + expect.Assert(codingTaskInProgress && issue.Status == models.StatusClosed, + "The issue should be marked as Closed after completing the task.") + return expect.Complete() } diff --git a/cmd/pm/tasks/createIssue.go b/cmd/pm/tasks/createIssue.go index b10c32c..a600c47 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,11 +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 -5. Close the issue once you've completed the work +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 are done. Make sure to fill out all the necessary details to help others understand the work item.` @@ -42,6 +42,10 @@ func (t *CreateIssueTask) Questions(interfaceType InterfaceType) Questions { return BaseQuestions(interfaceType) } +func (t *CreateIssueTask) QuestionnaireKeys(_ InterfaceType) []string { + return []string{"task_completed", "task_difficulty"} +} + func (t *CreateIssueTask) Setup(ctx context.Context) error { if err := ClearIssues(t.app); err != nil { return err @@ -63,9 +67,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 @@ -74,7 +75,13 @@ func (t *CreateIssueTask) Validate(ctx context.Context) ValidationFeedback { issue := issues[0] expect.Assert(len(issues) < 2, "Multiple issues were created instead of one") - expect.NotEmptyString(issue.Description, "Issue description should not be empty") + + expect.NotEmptyAndEqual(issue.Title, "My first Issue", "Issue title") + expect.NotEmptyAndEqual(issue.Description, "I need to do some coding", "Issue description") + expect.NotEmptyAndEqual(issue.Assignee, "Me", "Issue assignee") + + expect.Assert(issue.Status == models.StatusInProgress, + fmt.Sprintf("Issue status should be 'In Progress', but was '%s'", issue.Status)) return expect.Complete() } diff --git a/cmd/pm/tasks/gitTask.go b/cmd/pm/tasks/gitTask.go index 12f39c2..6520339 100644 --- a/cmd/pm/tasks/gitTask.go +++ b/cmd/pm/tasks/gitTask.go @@ -47,6 +47,7 @@ func (t *GitTask) Questions(interfaceType InterfaceType) Questions { return BaseQuestions(interfaceType).With( huh.NewGroup( huh.NewSelect[string]().Title("What Git Interface did you use?"). + Key("git_interface_used"). Options( huh.Option[string]{Value: "cli", Key: "Command Line Interface"}, huh.Option[string]{Value: "tui", Key: "Terminal User Interface"}, @@ -56,6 +57,11 @@ func (t *GitTask) Questions(interfaceType InterfaceType) Questions { ) } +func (t *GitTask) QuestionnaireKeys(interfaceType InterfaceType) []string { + _ = interfaceType + return []string{"task_completed", "task_difficulty", "git_interface_used"} +} + func (t *GitTask) Setup(ctx context.Context) error { if err := ClearIssues(t.app); err != nil { return err diff --git a/go.mod b/go.mod index d87e220..ff1e7b2 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/go-git/go-git/v6 v6.0.0-20260222090600-424e9964d3a3 github.com/muesli/reflow v0.3.0 github.com/steveyegge/beads v0.49.6 + go.mongodb.org/mongo-driver v1.17.9 ) // Terminal dependencies @@ -67,9 +68,11 @@ require ( github.com/go-playground/universal-translator v0.18.1 // indirect 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/inconshreveable/mousetrap v1.1.0 // indirect github.com/kevinburke/ssh_config v1.5.0 // indirect + github.com/klauspost/compress v1.18.0 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect @@ -79,6 +82,7 @@ require ( github.com/mattn/go-runewidth v0.0.19 // indirect github.com/mattn/go-tty v0.0.7 // indirect github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect + github.com/montanaflynn/stats v0.7.1 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/mango v0.2.0 // indirect @@ -102,7 +106,11 @@ require ( github.com/spf13/viper v1.21.0 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/tetratelabs/wazero v1.11.0 // indirect + github.com/xdg-go/pbkdf2 v1.0.0 // indirect + github.com/xdg-go/scram v1.2.0 // indirect + github.com/xdg-go/stringprep v1.0.4 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/crypto v0.48.0 // indirect golang.org/x/exp v0.0.0-20260209203927-2842357ff358 // indirect diff --git a/go.sum b/go.sum index 30fb654..9e37a12 100644 --- a/go.sum +++ b/go.sum @@ -124,6 +124,8 @@ github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPE github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= +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= @@ -132,6 +134,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 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= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= @@ -168,6 +172,8 @@ github.com/mattn/go-tty v0.0.7 h1:KJ486B6qI8+wBO7kQxYgmmEFDaFEE96JMBQ7h400N8Q= github.com/mattn/go-tty v0.0.7/go.mod h1:f2i5ZOvXBU/tCABmLmOfzLz9azMo5wdAaElRNnJKr+k= github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= +github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE= +github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= @@ -235,23 +241,43 @@ github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8 github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/tetratelabs/wazero v1.11.0 h1:+gKemEuKCTevU4d7ZTzlsvgd1uaToIDtlQlmNbwqYhA= github.com/tetratelabs/wazero v1.11.0/go.mod h1:eV28rsN8Q+xwjogd7f4/Pp4xFxO7uOGbLcD/LzB1wiU= +github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= +github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= +github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs= +github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8= +github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= +github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.mongodb.org/mongo-driver v1.17.9 h1:IexDdCuuNJ3BHrELgBlyaH9p60JXAvdzWR128q+U5tU= +go.mongodb.org/mongo-driver v1.17.9/go.mod h1:LlOhpH5NUEfhxcAwG0UEkMqwYcc4JU18gtCdGudk/tQ= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/exp v0.0.0-20260209203927-2842357ff358 h1:kpfSV7uLwKJbFSEgNhWzGSL47NDSF/5pYYQw1V0ub6c= golang.org/x/exp v0.0.0-20260209203927-2842357ff358/go.mod h1:R3t0oliuryB5eenPWl3rrQxwnNM3WTwnsRZZiXLAAW8= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -259,16 +285,30 @@ golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200909081042-eff7692f9009/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200918174421-af09f7315aff/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= diff --git a/internal/app/app.go b/internal/app/app.go index bb7a4cb..b44afa2 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -46,7 +46,7 @@ func New(ctx context.Context, config Config, opts ...Option) (*App, func(), erro StartTime: time.Now(), }) - jsonStatService, err := NewStatisticsService(statStore) + jsonStatService, err := NewStatisticsService(statStore, b.logger) if err != nil { return nil, nil, err } @@ -58,8 +58,9 @@ func New(ctx context.Context, config Config, opts ...Option) (*App, func(), erro Config: config, Logger: b.logger, - Issues: b.issueService, - Stats: b.statsService, + Issues: b.issueService, + Stats: b.statsService, + ActionLogger: config.ActionLogger, } return app, b.lifecycle.Close, nil diff --git a/internal/app/initializer.go b/internal/app/initializer.go index 6b883ab..c523275 100644 --- a/internal/app/initializer.go +++ b/internal/app/initializer.go @@ -16,11 +16,9 @@ type InteractiveInitializer struct{} func (i InteractiveInitializer) Init(path string) error { _, err := os.Stat(path) if err == nil { - // Path exists; assume already initialized. return nil } if !os.IsNotExist(err) { - // Some other filesystem error; propagate it to the caller. return err } diff --git a/internal/app/options.go b/internal/app/options.go index 840c865..86d4341 100644 --- a/internal/app/options.go +++ b/internal/app/options.go @@ -2,8 +2,10 @@ package app import ( "context" + "io" "log/slog" "os" + "path/filepath" "github.com/LazyBachelor/LazyPM/internal/models" ) @@ -23,15 +25,43 @@ type AppBuilder struct { } func defaultBuilder(ctx context.Context, config Config) *AppBuilder { + lifecycle := NewLifecycle() + logger := defaultLogger(config, lifecycle) + return &AppBuilder{ ctx: ctx, config: config, - lifecycle: NewLifecycle(), + lifecycle: lifecycle, initializer: &InteractiveInitializer{}, - logger: slog.New(slog.NewJSONHandler(os.Stdout, nil)), + logger: logger, } } +func defaultLogger(config Config, lifecycle *Lifecycle) *slog.Logger { + statsDir := filepath.Dir(config.StatisticsStoragePath) + if statsDir == "" { + statsDir = "." + } + + if err := os.MkdirAll(statsDir, 0o755); err != nil { + return slog.New(slog.NewJSONHandler(io.Discard, nil)) + } + + logPath := filepath.Join(statsDir, "app-logs.jsonl") + logFile, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) + if err != nil { + return slog.New(slog.NewJSONHandler(io.Discard, nil)) + } + + if lifecycle != nil { + lifecycle.Add(func() { + _ = logFile.Close() + }) + } + + return slog.New(slog.NewJSONHandler(logFile, &slog.HandlerOptions{})) +} + func WithLogger(logger *slog.Logger) Option { return func(b *AppBuilder) error { b.logger = logger diff --git a/internal/app/statistics.go b/internal/app/statistics.go index 7e00386..dadc342 100644 --- a/internal/app/statistics.go +++ b/internal/app/statistics.go @@ -2,7 +2,10 @@ package app import ( "context" - "errors" + "fmt" + "log/slog" + "sync" + "time" "github.com/LazyBachelor/LazyPM/internal/models" "github.com/LazyBachelor/LazyPM/internal/storage" @@ -10,14 +13,17 @@ import ( type StatisticsService struct { storage *storage.Storage[models.Statistics] + logger *slog.Logger + mu sync.Mutex } -func NewStatisticsService(storage *storage.Storage[models.Statistics]) (*StatisticsService, error) { +func NewStatisticsService(storage *storage.Storage[models.Statistics], logger *slog.Logger) (*StatisticsService, error) { if err := storage.Init(); err != nil { return nil, err } return &StatisticsService{ storage: storage, + logger: logger, }, nil } @@ -31,7 +37,86 @@ func (s *StatisticsService) Save(ctx context.Context) error { func (s *StatisticsService) GetStatistics() (models.Statistics, error) { if s.storage.Data == nil { - return models.Statistics{}, errors.New("statistics data not initialized") + return models.Statistics{}, fmt.Errorf("statistics data not initialized") } return *s.storage.Data, nil } + +func (s *StatisticsService) RecordTaskRun(ctx context.Context, run models.TaskRunMetrics) error { + _ = ctx + + s.mu.Lock() + defer s.mu.Unlock() + + if s.storage.Data == nil { + return fmt.Errorf("statistics data not initialized") + } + + stats := s.storage.Data + now := time.Now() + + if stats.StartTime.IsZero() { + stats.StartTime = run.StartedAt + } + if run.StartedAt.Before(stats.StartTime) { + stats.StartTime = run.StartedAt + } + + stats.EndTime = now + stats.Duration = stats.EndTime.Sub(stats.StartTime) + stats.InterfaceType = run.InterfaceType + + stats.TaskRuns++ + stats.LastTaskName = run.TaskName + stats.LastRunID = run.RunID + + if run.Completed { + stats.TasksCompleted++ + } else { + stats.TasksFailed++ + } + + stats.TotalDurationMs += run.DurationMs + if stats.TaskRuns > 0 { + stats.AverageDurationMs = stats.TotalDurationMs / int64(stats.TaskRuns) + } + + stats.ValidationAttempts += run.ValidationAttempts + stats.ValidationSuccesses += run.ValidationSuccesses + stats.ValidationFailures += run.ValidationFailures + stats.ValidationChecksPassed += run.ValidationChecksPassed + stats.ValidationChecksFailed += run.ValidationChecksFailed + + userActions := 0 + for _, log := range run.Logs { + if log.Level == "user_action" { + userActions++ + } + } + stats.TotalUserActions += userActions + if run.QuestionnaireCompleted { + stats.QuestionnairesCompleted++ + } + if run.QuestionnaireUserQuit { + stats.QuestionnairesAbandoned++ + } + + if err := s.storage.Save(); err != nil { + if s.logger != nil { + s.logger.Error("failed to save global statistics", "error", err) + } + return err + } + + if s.logger != nil { + s.logger.Info("global statistics updated", + "task", run.TaskName, + "run_id", run.RunID, + "task_runs", stats.TaskRuns, + "completed", stats.TasksCompleted, + "failed", stats.TasksFailed, + ) + } + + return nil +} diff --git a/internal/commands/survey/status.go b/internal/commands/survey/status.go index 2168d18..8df5ac0 100644 --- a/internal/commands/survey/status.go +++ b/internal/commands/survey/status.go @@ -1,6 +1,8 @@ package survey import ( + "time" + "github.com/LazyBachelor/LazyPM/internal/commands/issues" "github.com/spf13/cobra" ) @@ -15,16 +17,37 @@ var StatusCmd = &cobra.Command{ func runStatusCmd(cmd *cobra.Command, args []string) error { app := issues.AppFromContext(cmd.Context()) - if app == nil || app.CurrentFeedback == nil { + if app == nil { cmd.Println("No validation status available.") return nil } + if app.SubmitChan != nil { + select { + case app.SubmitChan <- struct{}{}: + default: + } + } + + time.Sleep(100 * time.Millisecond) + + if app.CurrentFeedback == nil { + cmd.Println("No validation status available yet.") + return nil + } + if app.CurrentFeedback.Message == "" { cmd.Println("No validation status available yet.") return nil } - cmd.Print(app.CurrentFeedback.Message) + cmd.Println(app.CurrentFeedback.Message) + for _, check := range app.CurrentFeedback.Checks { + if check.Valid { + cmd.Printf("✅ %s\n", check.Message) + } else { + cmd.Printf("❌ %s\n", check.Message) + } + } return nil } diff --git a/internal/commands/survey/submit.go b/internal/commands/survey/submit.go index 205d93e..8d0a7b2 100644 --- a/internal/commands/survey/submit.go +++ b/internal/commands/survey/submit.go @@ -1,12 +1,123 @@ package survey -import "github.com/spf13/cobra" +import ( + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/LazyBachelor/LazyPM/internal/models" + "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 { - cmd.Println("Submitting responses and metrics...") + client, error := mongo.Connect(cmd.Context(), options.Client().ApplyURI(mongoURI)) + if error != nil { + return fmt.Errorf("Failed to connect to MongoDB: %v", error) + } + + go func() { + if err := client.Disconnect(cmd.Context()); err != nil { + fmt.Printf("Failed to disconnect MongoDB client: %v", err) + } + }() + + userStatscollection := client.Database("Responses").Collection("stats") + taskMetricsCollection := client.Database("Responses").Collection("task_metrics") + + pmDir := "./.pm/" + + 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.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") + 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/action_event.go b/internal/models/action_event.go new file mode 100644 index 0000000..3b43b1d --- /dev/null +++ b/internal/models/action_event.go @@ -0,0 +1,29 @@ +package models + +import "encoding/json" + +type ActionEvent struct { + Source string `json:"source"` + Action string `json:"action"` + Target string `json:"target,omitempty"` + Result string `json:"result,omitempty"` +} + +func EncodeActionEvent(event ActionEvent) string { + bytes, err := json.Marshal(event) + if err != nil { + return event.Action + } + return string(bytes) +} + +func DecodeActionEvent(raw string) (ActionEvent, bool) { + var event ActionEvent + if err := json.Unmarshal([]byte(raw), &event); err != nil { + return ActionEvent{}, false + } + if event.Source == "" && event.Action == "" && event.Target == "" && event.Result == "" { + return ActionEvent{}, false + } + return event, true +} diff --git a/internal/models/app.go b/internal/models/app.go index 4a3b30d..5ba33b1 100644 --- a/internal/models/app.go +++ b/internal/models/app.go @@ -16,6 +16,21 @@ type App struct { Interfaces map[string]Interface CurrentFeedback *ValidationFeedback + ActionLogger func(string) + + SubmitChan chan<- struct{} +} + +func (a *App) LogAction(action string) { + if a == nil || action == "" { + return + } + if a.Logger != nil { + a.Logger.Info("action event", "action", action) + } + if a.ActionLogger != nil { + a.ActionLogger(action) + } } // IssueService defines the interface for managing issues in the application. @@ -42,4 +57,5 @@ type StatsService interface { Load(ctx context.Context) error Save(ctx context.Context) error GetStatistics() (Statistics, error) + RecordTaskRun(ctx context.Context, run TaskRunMetrics) error } diff --git a/internal/models/config.go b/internal/models/config.go index c5a6ce5..48a7f62 100644 --- a/internal/models/config.go +++ b/internal/models/config.go @@ -7,6 +7,7 @@ type Config struct { BeadsDBPath string IssuePrefix string StatisticsStoragePath string + ActionLogger func(string) } var BaseConfig = Config{ @@ -47,3 +48,8 @@ func (c Config) WithStatisticsStoragePath(path string) Config { c.StatisticsStoragePath = path return c } + +func (c Config) WithActionLogger(logger func(string)) Config { + c.ActionLogger = logger + return c +} diff --git a/internal/models/issueBuilder.go b/internal/models/issue_builder.go similarity index 100% rename from internal/models/issueBuilder.go rename to internal/models/issue_builder.go diff --git a/internal/models/statistics.go b/internal/models/statistics.go index 8ad224a..b151072 100644 --- a/internal/models/statistics.go +++ b/internal/models/statistics.go @@ -11,10 +11,82 @@ type Statistics struct { Duration time.Duration `json:"duration"` InterfaceType InterfaceType `json:"interface_type"` + TaskRuns int `json:"task_runs"` TasksCompleted int `json:"tasks_completed"` - ButtonClicks ButtonClicks `json:"button_clicks"` + TasksFailed int `json:"tasks_failed"` + + 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"` + + LastTaskName string `json:"last_task_name"` + LastRunID int `json:"last_run_id"` } -type ButtonClicks struct { - Clicks int `json:"clicks"` +type TaskMetricsFile struct { + TaskName string `json:"task_name"` + UpdatedAt time.Time `json:"updated_at"` + Summary TaskStatsSummary `json:"summary"` + Runs []TaskRunMetrics `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"` +} + +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"` +} + +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"` } diff --git a/internal/models/task.go b/internal/models/task.go index 9f0538b..88d385a 100644 --- a/internal/models/task.go +++ b/internal/models/task.go @@ -28,6 +28,7 @@ type Check struct { type ValidatedInterface interface { Interface SetChannels(feedbackChan chan ValidationFeedback, quitChan chan bool) + SetSubmitChan(submitChan chan<- struct{}) } type TaskDetails struct { diff --git a/internal/utils/check/expect.go b/internal/utils/check/expect.go index cfa40a2..8850d83 100644 --- a/internal/utils/check/expect.go +++ b/internal/utils/check/expect.go @@ -35,8 +35,11 @@ func (e *Expector) Complete() ValidationFeedback { return e.ValidationFeedback } -func (e *Expector) Fail(message string) ValidationFeedback { - e.Checks = append(e.Checks, NewCheck(message, false)) +func (e *Expector) CompleteWithMessage(message string) ValidationFeedback { + e.Success = len(e.Errors()) == 0 + if !e.Success { + e.Message = message + } return e.ValidationFeedback } @@ -50,6 +53,32 @@ func (e *Expector) Errors() []error { return errors } +func (e *Expector) Pass(message string) *Expector { + e.Checks = append(e.Checks, NewCheck(message, true)) + return e +} + +func (e *Expector) Fail(message string) *Expector { + e.Checks = append(e.Checks, NewCheck(message, false)) + return e +} + +func (e *Expector) NotEmptyAndEqual(val, expected string, message string) *Expector { + if val == "" { + return e.Fail(fmt.Sprintf("%s is empty", message)) + } else if val != expected { + return e.Fail(fmt.Sprintf(`%s expected "%v", got "%v"`, message, expected, val)) + } + return e.Pass(message + " is correct") +} + +func (e *Expector) Equal(val, expected any, message string) *Expector { + if val != expected { + return e.Fail(fmt.Sprintf(`%s expected "%v", got "%v"`, message, expected, val)) + } + return e.Pass(message + " is correct") +} + func (e *Expector) Assert(condition bool, message string) *Expector { check := NewCheck(message, condition) e.Checks = append(e.Checks, check) diff --git a/notebooks/task-data-analysis.ipynb b/notebooks/task-data-analysis.ipynb new file mode 100644 index 0000000..de212e6 --- /dev/null +++ b/notebooks/task-data-analysis.ipynb @@ -0,0 +1,418 @@ +{ + "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/repl.go b/pkg/repl/repl.go index 0c999f0..f91d475 100644 --- a/pkg/repl/repl.go +++ b/pkg/repl/repl.go @@ -31,6 +31,7 @@ You can also run shell commands directly. Type 'exit' or 'quit' to leave.` type REPL struct { feedbackChan chan ValidationFeedback quitChan chan bool + submitChan chan<- struct{} app *App currentFeedback ValidationFeedback @@ -65,7 +66,11 @@ func (r *REPL) Run(ctx context.Context, config app.Config) error { // Store app reference for updating feedback r.app = app - fmt.Println(style.TitleStyle.Render(ReplTitle)) // Print REPL title. + if r.submitChan != nil { + r.app.SubmitChan = r.submitChan + } + + fmt.Println(style.TitleStyle.Render(ReplTitle)) // Start goroutine to watch for validation feedback and quit signals if r.feedbackChan != nil && r.quitChan != nil { @@ -100,10 +105,15 @@ func (r *REPL) Run(ctx context.Context, config app.Config) error { // If the user types "exit" or "quit", break the loop and exit the REPL. if input == "exit" || input == "quit" { + r.logAction("repl exit requested") fmt.Println("Goodbye!") break } + if input != "" { + r.logAction("repl command: " + input) + } + // Add the input to the history for future navigation. history = append(history, input) @@ -124,6 +134,7 @@ func (r *REPL) watchValidation() { r.app.CurrentFeedback = &ValidationFeedback{ Success: feedback.Success, Message: feedback.Message, + Checks: feedback.Checks, } } if feedback.Success { @@ -144,3 +155,16 @@ func (r *REPL) SetChannels(feedbackChan chan task.ValidationFeedback, quitChan c r.feedbackChan = feedbackChan r.quitChan = quitChan } + +func (r *REPL) SetSubmitChan(submitChan chan<- struct{}) { + r.submitChan = submitChan +} + +func (r *REPL) logAction(action string) { + if r.app != nil { + r.app.LogAction(models.EncodeActionEvent(models.ActionEvent{ + Source: "repl", + Action: action, + })) + } +} diff --git a/pkg/task/lifecycle.go b/pkg/task/lifecycle.go new file mode 100644 index 0000000..8a9fa29 --- /dev/null +++ b/pkg/task/lifecycle.go @@ -0,0 +1,39 @@ +package task + +import ( + "log/slog" + + "github.com/LazyBachelor/LazyPM/internal/models" +) + +type RunLifecycle struct { + collector *taskRunCollector + config Config + details models.TaskDetails + app *App + logger *slog.Logger + metricsStore MetricsStore +} + +func NewRunLifecycle(app *App, config Config, details models.TaskDetails, iType InterfaceType, logger *slog.Logger) *RunLifecycle { + + collector := newTaskRunCollector(details.Title, iType, logger) + + config = config.WithActionLogger(func(action string) { + collector.recordUserAction(action) + }) + + var store MetricsStore + if config.StatisticsStoragePath != "" { + store = NewFileMetricsStore(config.StatisticsStoragePath, logger) + } + + return &RunLifecycle{ + collector: collector, + config: config, + details: details, + app: app, + logger: logger, + metricsStore: store, + } +} diff --git a/pkg/task/metrics.go b/pkg/task/metrics.go new file mode 100644 index 0000000..ce573e1 --- /dev/null +++ b/pkg/task/metrics.go @@ -0,0 +1,326 @@ +package task + +import ( + "fmt" + "log/slog" + "regexp" + "sort" + "strings" + "sync" + "time" + + "github.com/LazyBachelor/LazyPM/internal/models" +) + +type taskRunCollector struct { + mu sync.Mutex + run models.TaskRunMetrics + logger *slog.Logger +} + +var nonWord = regexp.MustCompile(`[^a-z0-9]+`) + +func newTaskRunCollector(taskName string, interfaceType InterfaceType, logger *slog.Logger) *taskRunCollector { + return &taskRunCollector{ + run: models.TaskRunMetrics{ + TaskName: taskName, + InterfaceType: interfaceType, + StartedAt: time.Now(), + Logs: make([]models.TaskLogEntry, 0, 8), + }, + logger: logger, + } +} + +func (c *taskRunCollector) appendLog(entry models.TaskLogEntry) { + entry.Timestamp = time.Now() + + c.run.Logs = append(c.run.Logs, entry) + + if c.logger == nil { + return + } + + attrs := []any{ + "task", c.run.TaskName, + "interface", c.run.InterfaceType, + "action", entry.Action, + "result", entry.Result, + } + + switch entry.Level { + case "error": + c.logger.Error(entry.Message, attrs...) + case "warn": + c.logger.Warn(entry.Message, attrs...) + default: + c.logger.Info(entry.Message, attrs...) + } +} + +func (c *taskRunCollector) log(level, message string) { + result := "ok" + switch level { + case "error": + result = "failed" + case "warn": + result = "warning" + } + + c.mu.Lock() + defer c.mu.Unlock() + + c.appendLog(models.TaskLogEntry{ + Level: level, + Message: message, + Source: "system", + Action: normalizeAction(message), + Result: result, + }) +} + +func (c *taskRunCollector) recordUserAction(raw string) { + source, actionText, target, result := normalizeUserAction(raw) + + c.mu.Lock() + defer c.mu.Unlock() + + c.appendLog(models.TaskLogEntry{ + Level: "user_action", + Message: raw, + Source: source, + Action: normalizeAction(actionText), + Target: target, + Result: result, + }) +} + +func (c *taskRunCollector) recordQuestionnaire(completed bool, userQuit bool, answers map[string]any) { + c.mu.Lock() + defer c.mu.Unlock() + + c.run.QuestionnaireCompleted = completed + c.run.QuestionnaireUserQuit = userQuit + + if len(answers) > 0 { + c.run.QuestionnaireAnswers = answers + } + + result := "completed" + if userQuit { + result = "user_quit" + } else if !completed { + result = "incomplete" + } + + c.appendLog(models.TaskLogEntry{ + Level: "questionnaire", + Message: "questionnaire finished", + Source: "system", + Action: "questionnaire_finish", + Result: result, + }) + + if len(answers) == 0 { + return + } + + keys := make([]string, 0, len(answers)) + for k := range answers { + keys = append(keys, k) + } + sort.Strings(keys) + + for _, k := range keys { + v := answers[k] + if v == nil { + continue + } + + valueText := fmt.Sprintf("%v", v) + + c.appendLog(models.TaskLogEntry{ + Level: "questionnaire", + Message: fmt.Sprintf("questionnaire answer: %s=%s", k, valueText), + Source: "system", + Action: "questionnaire_answer", + Target: k, + Result: valueText, + }) + } +} + +func (c *taskRunCollector) recordValidation(feedback ValidationFeedback) { + c.mu.Lock() + + c.run.ValidationAttempts++ + attempt := c.run.ValidationAttempts + + result := "failed" + if feedback.Success { + result = "passed" + c.run.ValidationSuccesses++ + } else { + c.run.ValidationFailures++ + } + + c.appendLog(models.TaskLogEntry{ + Level: "validation", + Message: fmt.Sprintf("validation attempt %d", attempt), + Source: "system", + Action: "validate_attempt", + Result: result, + Attempt: attempt, + }) + + for _, check := range feedback.Checks { + checkResult := "failed" + if check.Valid { + checkResult = "passed" + c.run.ValidationChecksPassed++ + } else { + c.run.ValidationChecksFailed++ + } + + c.appendLog(models.TaskLogEntry{ + Level: "validation", + Message: fmt.Sprintf("validation check: %s", check.Message), + Source: "system", + Action: "validate_check", + Target: check.Message, + Result: checkResult, + Attempt: attempt, + }) + } + + c.run.LastValidationMessage = feedback.Message + c.mu.Unlock() +} + +func (c *taskRunCollector) setCompleted(completed bool) { + c.mu.Lock() + c.run.Completed = completed + c.mu.Unlock() +} + +func (c *taskRunCollector) setError(err error) { + if err == nil { + return + } + c.mu.Lock() + c.run.Error = err.Error() + c.mu.Unlock() +} + +func (c *taskRunCollector) finalize() models.TaskRunMetrics { + c.mu.Lock() + defer c.mu.Unlock() + + if c.run.EndedAt.IsZero() { + c.run.EndedAt = time.Now() + } + + c.run.DurationMs = + c.run.EndedAt.Sub(c.run.StartedAt).Milliseconds() + + final := c.run + final.Logs = append([]models.TaskLogEntry(nil), c.run.Logs...) + return final +} + +func normalizeUserAction(raw string) (source, actionText, target, result string) { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return "unknown", "unknown_action", "", "unknown" + } + + if event, ok := models.DecodeActionEvent(trimmed); ok { + source = strings.TrimSpace(event.Source) + if source == "" { + source = "unknown" + } + + actionText = stripSourcePrefix( + strings.TrimSpace(event.Action), + source, + ) + + target = strings.TrimSpace(event.Target) + result = strings.TrimSpace(event.Result) + + if result == "" { + result = inferActionResult(actionText) + } + return + } + + lower := strings.ToLower(trimmed) + + if strings.HasPrefix(lower, "web request:") { + rest := strings.TrimSpace(trimmed[len("web request:"):]) + return "web", "request", rest, "ok" + } + + if strings.HasPrefix(lower, "repl command:") { + cmd := strings.TrimSpace(trimmed[len("repl command:"):]) + return "repl", "run_command", cmd, "ok" + } + + return "unknown", trimmed, "", inferActionResult(trimmed) +} + +func stripSourcePrefix(actionText, source string) string { + actionText = strings.TrimSpace(actionText) + if actionText == "" || source == "" { + return actionText + } + + lowerSource := strings.ToLower(source) + lowerAction := strings.ToLower(actionText) + + if strings.HasPrefix(lowerAction, lowerSource+" ") { + return strings.TrimSpace(actionText[len(source):]) + } + if strings.HasPrefix(lowerAction, lowerSource+"_") { + return strings.TrimSpace(actionText[len(source)+1:]) + } + + return actionText +} + +func inferActionResult(actionText string) string { + lower := strings.ToLower(actionText) + + switch { + case strings.Contains(lower, "failed"): + return "failed" + case strings.Contains(lower, "canceled"): + return "canceled" + case strings.Contains(lower, "started"): + return "started" + case strings.Contains(lower, "submitted"): + return "submitted" + case strings.Contains(lower, "requested"): + return "requested" + default: + return "ok" + } +} + +func normalizeAction(input string) string { + lower := strings.ToLower(strings.TrimSpace(input)) + if lower == "" { + return "unknown_action" + } + + clean := strings.Trim( + nonWord.ReplaceAllString(lower, "_"), + "_", + ) + + if clean == "" { + return "unknown_action" + } + + return clean +} diff --git a/pkg/task/metrics_store.go b/pkg/task/metrics_store.go new file mode 100644 index 0000000..ae69f25 --- /dev/null +++ b/pkg/task/metrics_store.go @@ -0,0 +1,87 @@ +package task + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "os" + "path/filepath" + "time" + + "github.com/LazyBachelor/LazyPM/internal/models" +) + +type MetricsStore interface { + Append(ctx context.Context, taskName string, run models.TaskRunMetrics) error +} + +type FileMetricsStore struct { + path string + logger *slog.Logger +} + +func NewFileMetricsStore(path string, logger *slog.Logger) *FileMetricsStore { + return &FileMetricsStore{ + path: path, + logger: logger, + } +} + +func (s *FileMetricsStore) Append(ctx context.Context, taskName string, run models.TaskRunMetrics) error { + if s.path == "" { + return nil + } + + dir := filepath.Dir(s.path) + if dir != "." { + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("create metrics directory: %w", err) + } + } + + metrics := models.TaskMetricsFile{ + TaskName: taskName, + Runs: []models.TaskRunMetrics{}, + } + + if err := readMetrics(&metrics, s.path); err != nil { + return err + } + + if metrics.TaskName == "" { + metrics.TaskName = taskName + } + + run.RunID = len(metrics.Runs) + 1 + metrics.Runs = append(metrics.Runs, run) + metrics.Summary = buildTaskStatsSummary(metrics.Runs) + metrics.UpdatedAt = time.Now() + + data, err := json.MarshalIndent(metrics, "", " ") + if err != nil { + return fmt.Errorf("encode metrics: %w", err) + } + + if err := os.WriteFile(s.path, data, 0o644); err != nil { + return fmt.Errorf("write metrics file: %w", err) + } + + if s.logger != nil { + s.logger.Info("task metrics persisted", "path", s.path, "task", taskName, "run_id", run.RunID) + } + return nil +} + +func readMetrics(metrics *models.TaskMetricsFile, path string) error { + if bytes, err := os.ReadFile(path); err == nil { + if len(bytes) > 0 { + if err := json.Unmarshal(bytes, metrics); err != nil { + return fmt.Errorf("failed to parse metrics file: %w", err) + } + } + } else if !os.IsNotExist(err) { + return fmt.Errorf("failed to read metrics file: %w", err) + } + return nil +} diff --git a/pkg/task/metrics_summary.go b/pkg/task/metrics_summary.go new file mode 100644 index 0000000..3a847d2 --- /dev/null +++ b/pkg/task/metrics_summary.go @@ -0,0 +1,71 @@ +package task + +import ( + "github.com/LazyBachelor/LazyPM/internal/models" +) + +func buildTaskStatsSummary(runs []models.TaskRunMetrics) models.TaskStatsSummary { + var summary models.TaskStatsSummary + + if len(runs) == 0 { + return summary + } + + summary.TotalRuns = len(runs) + + for i, run := range runs { + + // First run handling + if i == 0 || run.StartedAt.Before(summary.FirstRunStartedAt) { + summary.FirstRunStartedAt = run.StartedAt + } + + if run.StartedAt.After(summary.LastRunStartedAt) { + summary.LastRunStartedAt = run.StartedAt + } + + if run.EndedAt.After(summary.LastRunEndedAt) { + summary.LastRunEndedAt = run.EndedAt + summary.LastInterfaceType = run.InterfaceType + } + + // Duration + summary.TotalDurationMs += run.DurationMs + + // Completion + if run.Completed { + summary.CompletedRuns++ + } else { + summary.IncompleteRuns++ + } + + // Validation + summary.ValidationAttempts += run.ValidationAttempts + summary.ValidationSuccesses += run.ValidationSuccesses + summary.ValidationFailures += run.ValidationFailures + summary.ValidationChecksPassed += run.ValidationChecksPassed + summary.ValidationChecksFailed += run.ValidationChecksFailed + + // Questionnaire + if run.QuestionnaireCompleted { + summary.QuestionnairesCompleted++ + } + if run.QuestionnaireUserQuit { + summary.QuestionnairesAbandoned++ + } + + // User actions + for _, log := range run.Logs { + if log.Level == "user_action" { + summary.TotalUserActions++ + } + } + } + + if summary.TotalRuns > 0 { + summary.AverageDurationMs = + summary.TotalDurationMs / int64(summary.TotalRuns) + } + + return summary +} diff --git a/pkg/task/questionnaire.go b/pkg/task/questionnaire.go index 09f8490..369ef08 100644 --- a/pkg/task/questionnaire.go +++ b/pkg/task/questionnaire.go @@ -13,17 +13,19 @@ type Questions = models.Questions type QuestionnaireModel struct { Questions form *huh.Form + keys []string width, height int userQuit bool } -func NewQuestionnaireModel(questions Questions) *QuestionnaireModel { +func NewQuestionnaireModel(questions Questions, keys []string) *QuestionnaireModel { form := huh.NewForm(questions...). WithTheme(style.HuhCenterTheme()).WithLayout(huh.LayoutGrid(1, 1)) return &QuestionnaireModel{ Questions: questions, form: form, + keys: keys, } } @@ -72,3 +74,26 @@ func (q *QuestionnaireModel) SetSize(width, height int) { func (q QuestionnaireModel) GetUserQuit() bool { return q.userQuit } + +func (q QuestionnaireModel) GetCompleted() bool { + return q.form != nil && q.form.State == huh.StateCompleted +} + +func (q QuestionnaireModel) GetAnswers() map[string]any { + if q.form == nil || len(q.keys) == 0 { + return nil + } + + answers := make(map[string]any) + for _, key := range q.keys { + if key == "" { + continue + } + answers[key] = q.form.Get(key) + } + if len(answers) == 0 { + return nil + } + + return answers +} diff --git a/pkg/task/runner.go b/pkg/task/runner.go index a9c4389..3e25791 100644 --- a/pkg/task/runner.go +++ b/pkg/task/runner.go @@ -3,7 +3,7 @@ package task import ( "context" "fmt" - "time" + "log/slog" "github.com/LazyBachelor/LazyPM/internal/models" tea "github.com/charmbracelet/bubbletea" @@ -11,107 +11,196 @@ import ( type App = models.App type Config = models.Config - type Tasker = models.Tasker - type Interface = models.Interface type InterfaceType = models.InterfaceType - type ValidatedInterface = models.ValidatedInterface type ValidationFeedback = models.ValidationFeedback +type QuestionnaireKeysProvider interface { + QuestionnaireKeys(InterfaceType) []string +} + var ErrUserQuit = models.ErrUserQuit -// RunTask orchestrates the complete task execution flow: -// 1. Setup the task -// 2. Show task intro screen -// 3. Run the interface -// 4. Start validation loop in background -// 5. Show questionnaire when done -func RunTask(ctx context.Context, t Tasker, i Interface, iType InterfaceType) error { - doneChan := make(chan bool, 1) - quitChan := make(chan bool, 1) - feedbackChan := make(chan ValidationFeedback, 10) +type TaskRunner struct { + app *App + logger *slog.Logger +} - if validated, ok := i.(ValidatedInterface); ok { - validated.SetChannels(feedbackChan, quitChan) +func NewTaskRunner(app *App) *TaskRunner { + var logger *slog.Logger + if app != nil { + logger = app.Logger } + return &TaskRunner{ + app: app, + logger: logger, + } +} - // Setup task +func (r *TaskRunner) Run(ctx context.Context, t Tasker, i Interface, iType InterfaceType) (runErr error) { + + config := t.Config() + details := t.Details() + + lifecycle := NewRunLifecycle(r.app, config, details, iType, r.logger) + + defer func() { + runErr = lifecycle.Finish(ctx, runErr) + }() + + collector := lifecycle.collector + config = lifecycle.config + + collector.log("info", "task run started") + + // Setup if err := t.Setup(ctx); err != nil { return fmt.Errorf("failed to setup task: %w", err) } - // Show task intro - detailsScreen := NewTaskModel(t.Details()) - model, err := tea.NewProgram(detailsScreen, tea.WithAltScreen()).Run() - if err != nil { + // Intro screen + if err := runIntro(details); err != nil { return err } - if m, ok := model.(interface{ GetUserQuit() bool }); ok && m.GetUserQuit() { - return ErrUserQuit + + // Validation + feedbackChan := make(chan ValidationFeedback, 10) + quitChan := make(chan bool, 1) + submitChan := make(chan struct{}, 1) + + if validated, ok := i.(ValidatedInterface); ok { + validated.SetChannels(feedbackChan, quitChan) + validated.SetSubmitChan(submitChan) } - // Start validation loop - go startValidationLoop(ctx, t, feedbackChan, doneChan, quitChan) + if r.app != nil { + r.app.SubmitChan = submitChan + } + + engine := &ValidationEngine{task: t} + doneChan, stopChan := engine.Start(ctx, submitChan, func(feedback ValidationFeedback) { + collector.recordValidation(feedback) + + if feedback.Success { + feedback.Message = "Task completed successfully!" + } else if feedback.Message == "" { + feedback.Message = "Task not completed!" + } + + if r.app != nil { + r.app.CurrentFeedback = &feedback + } + + select { + case feedbackChan <- feedback: + default: + } + }) // Run interface - interfaceDone := make(chan error, 1) + interfaceErr := make(chan error, 1) go func() { - interfaceDone <- i.Run(ctx, t.Config()) + interfaceErr <- i.Run(ctx, config) }() select { case <-doneChan: + close(stopChan) close(quitChan) - if err := <-interfaceDone; err != nil { - fmt.Printf("warning: interface error after task completion: %v\n", err) - } - fmt.Println("Task completed successfully!") + collector.setCompleted(true) - case err := <-interfaceDone: + case err := <-interfaceErr: + close(stopChan) close(quitChan) if err != nil { - return fmt.Errorf("failed to start task interface: %w", err) + return fmt.Errorf("task interface failed: %w", err) } - fmt.Println("Task incomplete - you exited early") } - // Show questionnaire - questions := t.Questions(iType) - questionare := NewQuestionnaireModel(questions) - model, err = tea.NewProgram(questionare, tea.WithAltScreen()).Run() + // Questionnaire + if err := runQuestionnaire(t, iType, collector); err != nil { + return err + } + + collector.log("info", "task run finished") + return nil +} + +func (r *RunLifecycle) Finish(ctx context.Context, runErr error) error { + + if runErr != nil { + r.collector.setError(runErr) + r.collector.log("error", runErr.Error()) + } + + run := r.collector.finalize() + + if r.metricsStore != nil { + if err := r.metricsStore.Append(ctx, r.details.Title, run); err != nil && runErr == nil { + return fmt.Errorf("persist metrics: %w", err) + } + } + + if r.app != nil && r.app.Stats != nil { + if err := r.app.Stats.RecordTaskRun(ctx, run); err != nil && runErr == nil { + return fmt.Errorf("update global stats: %w", err) + } + } + + return runErr +} + +func runIntro(details models.TaskDetails) error { + model, err := tea.NewProgram(NewTaskModel(details), tea.WithAltScreen()).Run() if err != nil { return err } - if m, ok := model.(interface{ GetUserQuit() bool }); ok && m.GetUserQuit() { - return ErrUserQuit + + if m, ok := model.(interface{ GetUserQuit() bool }); ok { + if m.GetUserQuit() { + return ErrUserQuit + } } return nil } -func startValidationLoop(ctx context.Context, t Tasker, feedbackChan chan ValidationFeedback, doneChan chan bool, quitChan chan bool) { - ticker := time.NewTicker(1 * time.Second) - defer ticker.Stop() +func runQuestionnaire(t Tasker, iType InterfaceType, collector *taskRunCollector) error { + questions := t.Questions(iType) - for { - select { - case <-ticker.C: - feedback := t.Validate(ctx) - if feedback.Success { - feedback.Message = "Task completed successfully!" - feedbackChan <- feedback - time.Sleep(4 * time.Second) - doneChan <- true - return - } - feedback.Message = "Task not completed!" - feedbackChan <- feedback - case <-quitChan: - return - case <-ctx.Done(): - return - } + keys := []string{} + if provider, ok := t.(QuestionnaireKeysProvider); ok { + keys = provider.QuestionnaireKeys(iType) } + + model, err := tea.NewProgram(NewQuestionnaireModel(questions, keys), tea.WithAltScreen()).Run() + if err != nil { + return err + } + + var completed bool + var answers map[string]any + + if m, ok := model.(interface { + GetCompleted() bool + GetAnswers() map[string]any + }); ok { + completed = m.GetCompleted() + answers = m.GetAnswers() + } + + userQuit := false + if m, ok := model.(interface{ GetUserQuit() bool }); ok { + userQuit = m.GetUserQuit() + } + + collector.recordQuestionnaire(completed, userQuit, answers) + + if userQuit { + return ErrUserQuit + } + + return nil } diff --git a/pkg/task/taskui.go b/pkg/task/taskui.go index cfdbea0..2f7086b 100644 --- a/pkg/task/taskui.go +++ b/pkg/task/taskui.go @@ -69,7 +69,7 @@ func (m TaskModel) View() string { style.TextStyle.Render("Terminal too small.")) } - boxWidth := min(m.width-10, 80) + boxWidth := min(m.width-10, 120) detailsText := fmt.Sprintf("Time to complete: %s | Difficulty: %s", m.TimeToComplete, m.Difficulty) diff --git a/pkg/task/validation.go b/pkg/task/validation.go new file mode 100644 index 0000000..f2957e3 --- /dev/null +++ b/pkg/task/validation.go @@ -0,0 +1,42 @@ +package task + +import ( + "context" + "time" +) + +type ValidationEngine struct { + task Tasker +} + +func (v *ValidationEngine) Start(ctx context.Context, submitChan <-chan struct{}, onFeedback func(ValidationFeedback)) (done <-chan struct{}, stop chan<- struct{}) { + doneChan := make(chan struct{}, 1) + stopChan := make(chan struct{}, 1) + + go func() { + for { + select { + case <-submitChan: + feedback := v.task.Validate(ctx) + + if onFeedback != nil { + onFeedback(feedback) + } + + if feedback.Success { + time.Sleep(3 * time.Second) + doneChan <- struct{}{} + return + } + + case <-stopChan: + return + + case <-ctx.Done(): + return + } + } + }() + + return doneChan, stopChan +} diff --git a/pkg/tui/tui.go b/pkg/tui/tui.go index 6227f27..92c3a87 100644 --- a/pkg/tui/tui.go +++ b/pkg/tui/tui.go @@ -15,6 +15,7 @@ type ValidationFeedback = models.ValidationFeedback type Tui struct { feedbackChan chan ValidationFeedback quitChan chan bool + submitChan chan<- struct{} } func New() *Tui { @@ -29,7 +30,7 @@ func (t *Tui) Run(ctx context.Context, config Config) error { defer cleanup() - p := tea.NewProgram(views.NewDashboardView(app, t.feedbackChan, t.quitChan), + p := tea.NewProgram(views.NewDashboardView(app, t.feedbackChan, t.quitChan, t.submitChan), tea.WithAltScreen(), tea.WithMouseAllMotion()) if t.quitChan != nil { @@ -50,3 +51,7 @@ func (t *Tui) SetChannels(feedbackChan chan ValidationFeedback, quitChan chan bo t.feedbackChan = feedbackChan t.quitChan = quitChan } + +func (t *Tui) SetSubmitChan(submitChan chan<- struct{}) { + t.submitChan = submitChan +} diff --git a/pkg/tui/views/dashboard/help_bar.go b/pkg/tui/views/dashboard/help_bar.go index 3ad63cc..39a1949 100644 --- a/pkg/tui/views/dashboard/help_bar.go +++ b/pkg/tui/views/dashboard/help_bar.go @@ -31,15 +31,16 @@ func (h HelpBar) View() string { func (h HelpBar) shortHelp() string { keys := []string{ - styles.HighlightKey("tab") + " switch", - styles.HighlightKey("↑/k") + " up", - styles.HighlightKey("↓/j") + " down", - styles.HighlightKey("pgup/pgdn") + " page", - styles.HighlightKey("a") + " add", - styles.HighlightKey("e/d/s/p/t") + " edit", - styles.HighlightKey("x") + " delete", - styles.HighlightKey("q") + " quit", - styles.HighlightKey("?") + " help", + styles.HighlightKey("tab") + "switch ", + styles.HighlightKey("↑/k") + "up ", + styles.HighlightKey("↓/j") + "down ", + styles.HighlightKey("pgup/pgdn") + "page ", + styles.HighlightKey("a") + "add ", + styles.HighlightKey("e/d/s/p/t/c") + "edit ", + styles.HighlightKey("x") + "delete ", + styles.HighlightKey("q") + "quit ", + styles.HighlightKey("S") + "submit ", + styles.HighlightKey("?") + "help ", } content := lipgloss.JoinHorizontal(lipgloss.Left, keys...) return lipgloss.NewStyle(). @@ -75,9 +76,10 @@ 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"), + renderRow("S", "submit task", "q", "quit"), } content := lipgloss.JoinVertical(lipgloss.Left, rows...) return lipgloss.NewStyle(). 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 34760f5..dd0981b 100644 --- a/pkg/tui/views/dashboard/keys.go +++ b/pkg/tui/views/dashboard/keys.go @@ -6,20 +6,22 @@ import ( ) type DashboardKeyMap struct { - Help key.Binding - Quit key.Binding - SelectIssue key.Binding - BackToList key.Binding - ScrollUp key.Binding - ScrollDown key.Binding - SwitchWindow key.Binding - EditTitle key.Binding - EditDescription key.Binding - ChangeStatus key.Binding - ChangePriority key.Binding - ChangeType key.Binding - AddIssue key.Binding - DeleteIssue key.Binding + Help key.Binding + Quit key.Binding + SelectIssue key.Binding + BackToList key.Binding + ScrollUp key.Binding + ScrollDown key.Binding + SwitchWindow key.Binding + EditTitle key.Binding + EditDescription key.Binding + ChangeStatus key.Binding + ChangePriority key.Binding + ChangeType key.Binding + AddComment key.Binding + AddIssue key.Binding + DeleteIssue key.Binding + SubmitValidation key.Binding } var defaultDashboardKeyMap = DashboardKeyMap{ @@ -71,6 +73,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"), @@ -79,6 +85,10 @@ var defaultDashboardKeyMap = DashboardKeyMap{ key.WithKeys("x"), key.WithHelp("x", "delete issue"), ), + SubmitValidation: key.NewBinding( + key.WithKeys("S"), + key.WithHelp("S", "submit validation"), + ), } func (d *Model) handleKeyMsg(msg tea.KeyMsg) tea.Cmd { @@ -87,47 +97,74 @@ func (d *Model) handleKeyMsg(msg tea.KeyMsg) tea.Cmd { switch { case key.Matches(msg, d.keyMap.Help): d.helpBar.ToggleHelp() + d.logAction("tui toggled help") case key.Matches(msg, d.keyMap.Quit): + d.logAction("tui quit requested") return tea.Quit case key.Matches(msg, d.keyMap.SwitchWindow): d.ToggleFocusedWindow() + d.logAction("tui switched issue window") case d.IsFocusedOnList() && key.Matches(msg, d.keyMap.SelectIssue): d.FocusDetail() + d.logAction("tui opened issue detail") case d.IsFocusedOnDetail() && key.Matches(msg, d.keyMap.BackToList): d.FocusList() + d.logAction("tui returned to issue list") case d.IsFocusedOnDetail() && key.Matches(msg, d.keyMap.ScrollUp): d.issueDetail.ScrollUp(1) + d.logAction("tui scrolled issue detail up") 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): + d.logAction("tui scrolled issue detail down") + 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() - case !d.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && !d.choosingPriority && !d.confirmingDelete && !d.choosingType && key.Matches(msg, d.keyMap.DeleteIssue): + d.logAction("tui started creating issue") + 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()) + d.logAction("tui opened delete confirmation") + } + case key.Matches(msg, d.keyMap.SubmitValidation): + if d.submitChan != nil { + select { + case d.submitChan <- struct{}{}: + d.logAction("tui submitted validation") + default: + } } } diff --git a/pkg/tui/views/dashboard/model.go b/pkg/tui/views/dashboard/model.go index dde0979..de1ce4b 100644 --- a/pkg/tui/views/dashboard/model.go +++ b/pkg/tui/views/dashboard/model.go @@ -46,14 +46,18 @@ type Model struct { choosingPriority bool // true while choosing a priority priorityIssueID string choosingType bool // true while choosing a type - typeIssueID string - feedbackChan chan models.ValidationFeedback - quitChan chan bool - currentFeedback models.ValidationFeedback - showComplete bool + typeIssueID string + addingComment bool // true while adding a comment + commentInput textarea.Model + commentIssueID string + feedbackChan chan models.ValidationFeedback + quitChan chan bool + currentFeedback models.ValidationFeedback + showComplete bool + submitChan chan<- struct{} } -func NewDashboard(app *app.App, feedbackChan chan models.ValidationFeedback, quitChan chan bool) *Model { +func NewDashboard(app *app.App, feedbackChan chan models.ValidationFeedback, quitChan chan bool, submitChan chan<- struct{}) *Model { m := &Model{ header: NewHeader("Project Manager Dashboard"), keyMap: defaultDashboardKeyMap, @@ -65,6 +69,7 @@ func NewDashboard(app *app.App, feedbackChan chan models.ValidationFeedback, qui focusedPaneClosed: 0, feedbackChan: feedbackChan, quitChan: quitChan, + submitChan: submitChan, } allIssues, _ := app.Issues.SearchIssues(context.Background(), "", models.IssueFilter{}) @@ -89,15 +94,48 @@ 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{ + Source: "tui", + Action: action, + })) + } +} + func (m *Model) startEditTitle(selected ListIssue) { m.editingTitle = true m.editingIssueID = selected.ID @@ -203,11 +241,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 8d53ee9..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 } } @@ -135,8 +162,10 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.editingIssueID = "" m.titleInput.Blur() if msg.Err != nil { + m.logAction("tui failed to update issue title") return m, nil } + m.logAction("tui updated issue title") return m, m.refreshIssueListsAndSelectIssue(msg.IssueID) case issueDescriptionUpdatedMsg: @@ -144,32 +173,40 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.editingDescIssueID = "" m.descriptionInput.Blur() if msg.Err != nil { + m.logAction("tui failed to update issue description") return m, nil } + m.logAction("tui updated issue description") return m, m.refreshIssueListsAndSelectIssue(msg.IssueID) case issueStatusUpdatedMsg: m.choosingStatus = false m.statusIssueID = "" if msg.Err != nil { + m.logAction("tui failed to update issue status") return m, nil } + m.logAction("tui updated issue status") return m, m.refreshIssueListsAndSelectIssue(msg.IssueID) case issuePriorityUpdatedMsg: m.choosingPriority = false m.priorityIssueID = "" if msg.Err != nil { + m.logAction("tui failed to update issue priority") return m, nil } + m.logAction("tui updated issue priority") return m, m.refreshIssueListsAndSelectIssue(msg.IssueID) case issueTypeUpdatedMsg: m.choosingType = false m.typeIssueID = "" if msg.Err != nil { + m.logAction("tui failed to update issue type") return m, nil } + m.logAction("tui updated issue type") return m, m.refreshIssueListsAndSelectIssue(msg.IssueID) case selectIssueMsg: @@ -182,6 +219,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.createTitleInput.Blur() m.createTitleInput.Reset() if msg.Err != nil || msg.Issue == nil { + m.logAction("tui failed to create issue") return m, nil } issues, err := m.app.Issues.SearchIssues(context.Background(), "", models.IssueFilter{}) @@ -203,14 +241,26 @@ 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 = "" if msg.Err != nil { + m.logAction("tui failed to delete issue") return m, nil } + m.logAction("tui deleted issue") issues, err := m.app.Issues.SearchIssues(context.Background(), "", models.IssueFilter{}) if err != nil { return m, nil @@ -221,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) } @@ -245,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) } @@ -254,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} }) @@ -263,12 +313,14 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.confirmingDelete { switch msg.String() { case "y", "Y": + m.logAction("tui confirmed issue deletion") issueID := m.deleteConfirmID idx := m.deleteConfirmIndex m.confirmingDelete = false m.deleteConfirmID = "" return m, deleteIssueCmd(m.app, issueID, idx) case "n", "N", "esc": + m.logAction("tui canceled issue deletion") m.confirmingDelete = false m.deleteConfirmID = "" return m, nil @@ -278,21 +330,25 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.choosingStatus { switch msg.String() { case "o": + m.logAction("tui selected issue status open") issueID := m.statusIssueID m.choosingStatus = false m.statusIssueID = "" return m, updateIssueStatusCmd(m.app, issueID, string(models.StatusOpen)) case "i": + m.logAction("tui selected issue status in_progress") issueID := m.statusIssueID m.choosingStatus = false m.statusIssueID = "" return m, updateIssueStatusCmd(m.app, issueID, string(models.StatusInProgress)) case "c": + m.logAction("tui selected issue status closed") issueID := m.statusIssueID m.choosingStatus = false m.statusIssueID = "" return m, updateIssueStatusCmd(m.app, issueID, string(models.StatusClosed)) case "esc": + m.logAction("tui canceled status picker") m.choosingStatus = false m.statusIssueID = "" return m, nil @@ -302,12 +358,14 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.choosingPriority { switch msg.String() { case "0", "1", "2", "3", "4": + m.logAction("tui selected issue priority") issueID := m.priorityIssueID priority := int(msg.String()[0] - '0') m.choosingPriority = false m.priorityIssueID = "" return m, updateIssuePriorityCmd(m.app, issueID, priority) case "esc": + m.logAction("tui canceled priority picker") m.choosingPriority = false m.priorityIssueID = "" return m, nil @@ -319,31 +377,37 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.choosingType { switch msg.String() { case "b": + m.logAction("tui selected issue type bug") issueID := m.typeIssueID m.choosingType = false m.typeIssueID = "" return m, updateIssueTypeCmd(m.app, issueID, models.TypeBug) case "f": + m.logAction("tui selected issue type feature") issueID := m.typeIssueID m.choosingType = false m.typeIssueID = "" return m, updateIssueTypeCmd(m.app, issueID, models.TypeFeature) case "t": + m.logAction("tui selected issue type task") issueID := m.typeIssueID m.choosingType = false m.typeIssueID = "" return m, updateIssueTypeCmd(m.app, issueID, models.TypeTask) case "e": + m.logAction("tui selected issue type epic") issueID := m.typeIssueID m.choosingType = false m.typeIssueID = "" return m, updateIssueTypeCmd(m.app, issueID, models.TypeEpic) case "c": + m.logAction("tui selected issue type chore") issueID := m.typeIssueID m.choosingType = false m.typeIssueID = "" return m, updateIssueTypeCmd(m.app, issueID, models.TypeChore) case "esc": + m.logAction("tui canceled type picker") m.choosingType = false m.typeIssueID = "" return m, nil @@ -356,10 +420,12 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.String() == "enter" { title := m.createTitleInput.Value() if title != "" { + m.logAction("tui submitted new issue") return m, createIssueCmd(m.app, title) } } if msg.String() == "esc" { + m.logAction("tui canceled issue creation") m.creatingIssue = false m.createTitleInput.Blur() m.createTitleInput.Reset() @@ -374,10 +440,12 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.String() == "enter" { newTitle := m.titleInput.Value() if newTitle != "" { + m.logAction("tui submitted issue title edit") return m, updateIssueTitleCmd(m.app, m.editingIssueID, newTitle) } } if msg.String() == "esc" { + m.logAction("tui canceled issue title edit") m.editingTitle = false m.editingIssueID = "" m.titleInput.Blur() @@ -388,8 +456,33 @@ 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") issueID := m.editingDescIssueID newDesc := m.descriptionInput.Value() m.editingDescription = false @@ -398,6 +491,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, updateIssueDescriptionCmd(m.app, issueID, newDesc) } if msg.String() == "esc" { + m.logAction("tui canceled issue description edit") m.editingDescription = false m.editingDescIssueID = "" m.descriptionInput.Blur() @@ -441,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 @@ -449,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) diff --git a/pkg/tui/views/views.go b/pkg/tui/views/views.go index 926a35c..1ba16e7 100644 --- a/pkg/tui/views/views.go +++ b/pkg/tui/views/views.go @@ -6,6 +6,6 @@ import ( "github.com/LazyBachelor/LazyPM/pkg/tui/views/dashboard" ) -func NewDashboardView(app *app.App, feedbackChan chan models.ValidationFeedback, quitChan chan bool) *dashboard.Model { - return dashboard.NewDashboard(app, feedbackChan, quitChan) +func NewDashboardView(app *app.App, feedbackChan chan models.ValidationFeedback, quitChan chan bool, submitChan chan<- struct{}) *dashboard.Model { + return dashboard.NewDashboard(app, feedbackChan, quitChan, submitChan) } diff --git a/pkg/web/components/status.templ b/pkg/web/components/status.templ index 28b4ee1..ad255c0 100644 --- a/pkg/web/components/status.templ +++ b/pkg/web/components/status.templ @@ -1,7 +1,7 @@ package components templ Status() { -
`+check.Message+`
`) + io.WriteString(w, ``+"❌ "+check.Message+`
`) + } else { + io.WriteString(w, ``+"✅ "+check.Message+`
`) } } + return nil }) } diff --git a/pkg/web/server/routes.go b/pkg/web/server/routes.go index ed60e45..9004329 100644 --- a/pkg/web/server/routes.go +++ b/pkg/web/server/routes.go @@ -9,6 +9,7 @@ import ( "strings" "time" + "github.com/LazyBachelor/LazyPM/internal/models" "github.com/LazyBachelor/LazyPM/pkg/web/handler" "github.com/NYTimes/gziphandler" "github.com/go-chi/chi/v5" @@ -19,13 +20,16 @@ import ( func (s *Server) RegisterRoutes(assets embed.FS) http.Handler { r := chi.NewRouter() - r.Use(middleware.Logger) + if os.Getenv("DEV") == "True" { + r.Use(middleware.Logger) + } r.Use(middleware.Recoverer) r.Use(cors.AllowAll().Handler) r.Use(middleware.CleanPath) r.Use(handler.HTMXMiddleware) r.Use(handler.AppMiddleware(s.App)) + r.Use(actionLoggingMiddleware(s.App)) s.handleAssets(r, assets) @@ -57,6 +61,35 @@ func (s *Server) RegisterRoutes(assets embed.FS) http.Handler { return gziphandler.GzipHandler(r) } +func actionLoggingMiddleware(app interface{ LogAction(string) }) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if app != nil && shouldLogWebAction(r.URL.Path) { + app.LogAction(models.EncodeActionEvent(models.ActionEvent{ + Source: "web", + Action: "request", + Target: r.Method + " " + r.URL.Path, + Result: "ok", + })) + } + next.ServeHTTP(w, r) + }) + } +} + +func shouldLogWebAction(path string) bool { + if strings.HasPrefix(path, "/assets/") { + return false + } + + switch path { + case "/status": + return false + default: + return true + } +} + func (s *Server) handleAssets(r chi.Router, assets embed.FS) { var fileServer http.Handler diff --git a/pkg/web/web.go b/pkg/web/web.go index 4f23852..77b14c3 100644 --- a/pkg/web/web.go +++ b/pkg/web/web.go @@ -22,6 +22,7 @@ type ValidationFeedback = models.ValidationFeedback type Web struct { feedbackChan chan ValidationFeedback quitChan chan bool + submitChan chan<- struct{} } func New() *Web { @@ -72,6 +73,10 @@ func (w *Web) Run(ctx context.Context, config Config) error { }() } + if w.submitChan != nil { + handler.SetSubmitChan(w.submitChan) + } + select { case <-w.quitChan: fmt.Println("Task completed! Shutting down server...") @@ -89,3 +94,7 @@ func (w *Web) SetChannels(feedbackChan chan ValidationFeedback, quitChan chan bo w.feedbackChan = feedbackChan w.quitChan = quitChan } + +func (w *Web) SetSubmitChan(submitChan chan<- struct{}) { + w.submitChan = submitChan +}