From ea32c9534a2d34648701aab0f15345885e19831f Mon Sep 17 00:00:00 2001 From: Nick Schuch Date: Wed, 15 Apr 2026 12:55:03 +1000 Subject: [PATCH] PoC: Vibed up ability to track AI time savings --- cmd/add/command.go | 20 +- cmd/add/command_test.go | 73 ++++++++ cmd/ai-graph/main.go | 302 +++++++++++++++++++++++++++++++ cmd/aits/command.go | 67 +++++++ cmd/aits/command_test.go | 90 +++++++++ cmd/edit/command.go | 25 ++- cmd/list/command.go | 4 + cmd/review/command.go | 5 +- cmd/root_cmd.go | 2 + cmd/send/command.go | 18 +- cmd/show/command.go | 1 + cmd/timer/stop/command.go | 23 ++- internal/api/jira_client.go | 3 + internal/api/mock/mock_client.go | 12 ++ internal/api/types/types.go | 51 +++++- internal/api/work_logs.go | 186 ++++++++++++++++--- internal/api/work_logs_test.go | 160 +++++++++++++++- internal/model/time_entry.go | 13 +- internal/service/timer_entry.go | 12 +- 19 files changed, 1008 insertions(+), 59 deletions(-) create mode 100644 cmd/ai-graph/main.go create mode 100644 cmd/aits/command.go create mode 100644 cmd/aits/command_test.go diff --git a/cmd/add/command.go b/cmd/add/command.go index 474a8eb..069a3cf 100644 --- a/cmd/add/command.go +++ b/cmd/add/command.go @@ -16,8 +16,14 @@ var ( cmdLong = `Add a time entry` cmdExample = ` # Add 2 hours to a project a project with issue ID PNX-123 - tl add PNX-123 2h "Worked on feature X"` - date time.Time + tl add PNX-123 2h "Worked on feature X" + + # Add 2 hours and indicate 1 hour was saved by AI + tl add PNX-123 2h "Worked on feature X" --ai-time-saved 1h + tl add PNX-123 2h "Worked on feature X" --aits 1h + tl add PNX-123 2h "Worked on feature X" -a 1h` + date time.Time + aiTimeSavedStr string ) func NewCommand(r func() db.TimeEntriesInterface, s func() service.SyncInterface, i func() db.IssueStorageInterface) *cobra.Command { @@ -84,6 +90,13 @@ func NewCommand(r func() db.TimeEntriesInterface, s func() service.SyncInterface if len(args) > 2 { entry.Description = args[2] } + if aiTimeSavedStr != "" { + aiDur, err := time.ParseDuration(aiTimeSavedStr) + if err != nil { + return fmt.Errorf("invalid AI time saved duration: %s", aiTimeSavedStr) + } + entry.AISavedDuration = aiDur + } entry.CreatedAt = date err = r().CreateTimeEntry(entry) @@ -101,6 +114,9 @@ func NewCommand(r func() db.TimeEntriesInterface, s func() service.SyncInterface time.DateOnly, } cmd.Flags().TimeVarP(&date, "date", "d", time.Now(), timeFormats, "Date for the entry.") + cmd.Flags().StringVarP(&aiTimeSavedStr, "ai-time-saved", "a", "", "Duration of time saved by AI (e.g. 1h, 30m)") + cmd.Flags().StringVar(&aiTimeSavedStr, "aits", "", "Duration of time saved by AI (shorthand for --ai-time-saved)") + _ = cmd.Flags().MarkHidden("aits") return cmd } diff --git a/cmd/add/command_test.go b/cmd/add/command_test.go index 6a89210..c2e2b07 100644 --- a/cmd/add/command_test.go +++ b/cmd/add/command_test.go @@ -3,6 +3,7 @@ package add import ( "bytes" "testing" + "time" "github.com/stretchr/testify/assert" @@ -61,3 +62,75 @@ func TestAdd_NoDescription(t *testing.T) { output := buf.String() assert.Contains(t, output, "Added time entry: ID=42") } + +func TestAdd_WithAITimeSaved(t *testing.T) { + mock := &dbmocks.MockRepository{} + cmd := NewCommand( + func() db.TimeEntriesInterface { return mock }, + func() service.SyncInterface { return &servicemocks.MockSync{} }, + func() db.IssueStorageInterface { return mock }, + ) + + var buf bytes.Buffer + cmd.SetOut(&buf) + cmd.SetArgs([]string{"PNX-123", "2h", "Worked on feature X", "--ai-time-saved", "1h"}) + + err := cmd.Execute() + assert.NoError(t, err) + output := buf.String() + assert.Contains(t, output, "Added time entry: ID=42") + assert.Len(t, mock.Entries, 1) + assert.Equal(t, time.Hour, mock.Entries[0].AISavedDuration) +} + +func TestAdd_WithAITimeSaved_ShortFlag(t *testing.T) { + mock := &dbmocks.MockRepository{} + cmd := NewCommand( + func() db.TimeEntriesInterface { return mock }, + func() service.SyncInterface { return &servicemocks.MockSync{} }, + func() db.IssueStorageInterface { return mock }, + ) + + var buf bytes.Buffer + cmd.SetOut(&buf) + cmd.SetArgs([]string{"PNX-123", "2h", "Worked on feature X", "-a", "30m"}) + + err := cmd.Execute() + assert.NoError(t, err) + assert.Len(t, mock.Entries, 1) + assert.Equal(t, 30*time.Minute, mock.Entries[0].AISavedDuration) +} + +func TestAdd_WithAITimeSaved_AitsAlias(t *testing.T) { + mock := &dbmocks.MockRepository{} + cmd := NewCommand( + func() db.TimeEntriesInterface { return mock }, + func() service.SyncInterface { return &servicemocks.MockSync{} }, + func() db.IssueStorageInterface { return mock }, + ) + + var buf bytes.Buffer + cmd.SetOut(&buf) + cmd.SetArgs([]string{"PNX-123", "2h", "Worked on feature X", "--aits", "45m"}) + + err := cmd.Execute() + assert.NoError(t, err) + assert.Len(t, mock.Entries, 1) + assert.Equal(t, 45*time.Minute, mock.Entries[0].AISavedDuration) +} + +func TestAdd_InvalidAITimeSaved_ReturnsError(t *testing.T) { + cmd := NewCommand( + func() db.TimeEntriesInterface { return &dbmocks.MockRepository{} }, + func() service.SyncInterface { return &servicemocks.MockSync{} }, + func() db.IssueStorageInterface { return &dbmocks.MockRepository{} }, + ) + + var buf bytes.Buffer + cmd.SetOut(&buf) + cmd.SetArgs([]string{"PNX-123", "2h", "desc", "--ai-time-saved", "notaduration"}) + + err := cmd.Execute() + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid AI time saved duration") +} diff --git a/cmd/ai-graph/main.go b/cmd/ai-graph/main.go new file mode 100644 index 0000000..abf34aa --- /dev/null +++ b/cmd/ai-graph/main.go @@ -0,0 +1,302 @@ +package main + +import ( + "encoding/json" + "flag" + "fmt" + "log" + "math" + "net/http" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/jwalton/gchalk" + "github.com/spf13/viper" + + "github.com/previousnext/tl-go/internal/api" + "github.com/previousnext/tl-go/internal/api/types" + "github.com/previousnext/tl-go/internal/model" + "github.com/previousnext/tl-go/internal/util" +) + +// daySummary holds aggregated worklog data for a single day. +type daySummary struct { + Date time.Time + Logged time.Duration + AISaved time.Duration + WorklogIDs int +} + +func main() { + dateFlag := flag.String("date", "this week", "Date range: 'today', 'yesterday', 'this week', 'last week', 'this month', 'last month', or YYYY-MM-DD") + configFlag := flag.String("config", "", "Path to tl config file (default ~/.config/tl/config.yml)") + flag.Parse() + + // Load config + if err := loadConfig(*configFlag); err != nil { + log.Fatalf("Error loading config: %v", err) + } + + baseURL := viper.GetString("jira_base_url") + username := viper.GetString("jira_username") + apiToken := viper.GetString("jira_api_token") + + if baseURL == "" || username == "" || apiToken == "" { + log.Fatal("Missing Jira configuration. Ensure jira_base_url, jira_username, and jira_api_token are set in your tl config file.") + } + + // Parse date range + start, end, label, err := util.ParseHumanDate(*dateFlag, time.Now()) + if err != nil { + log.Fatalf("Invalid date: %v", err) + } + + fmt.Printf("Fetching worklogs for %s ...\n", label) + + // Build Jira client + jiraClient := api.NewJiraClient(&http.Client{}, types.JiraClientParams{ + BaseURL: baseURL, + Username: username, + APIToken: apiToken, + }) + + // Fetch all updated worklog IDs since the start of the period + changes, err := jiraClient.GetUpdatedWorklogIDs(start.UnixMilli()) + if err != nil { + log.Fatalf("Failed to fetch updated worklog IDs: %v", err) + } + + if len(changes) == 0 { + fmt.Println("No worklogs found for this period.") + return + } + + // Filter to only changes within our date range + endMillis := end.UnixMilli() + var filteredIDs []int64 + for _, c := range changes { + if c.UpdatedTime <= endMillis { + filteredIDs = append(filteredIDs, c.WorklogID) + } + } + + if len(filteredIDs) == 0 { + fmt.Println("No worklogs found for this period.") + return + } + + fmt.Printf("Found %d worklog updates, fetching details...\n", len(filteredIDs)) + + // Bulk fetch worklog details + worklogs, err := jiraClient.BulkGetWorklogs(filteredIDs) + if err != nil { + log.Fatalf("Failed to fetch worklog details: %v", err) + } + + // Filter worklogs to those whose started date falls within our range. + // The "started" field format from Jira is "2021-01-17T12:34:00.000+0000". + var filtered []types.Worklog + for _, w := range worklogs { + started, err := time.Parse(api.DateFormat, w.Started) + if err != nil { + // Try alternate format + started, err = time.Parse("2006-01-02T15:04:05.000+0000", w.Started) + if err != nil { + continue + } + } + if !started.Before(start) && !started.After(end) { + filtered = append(filtered, w) + } + } + + if len(filtered) == 0 { + fmt.Println("No worklogs with matching start dates found for this period.") + return + } + + fmt.Printf("Fetching AI time saved properties for %d worklogs...\n", len(filtered)) + + // For each worklog, fetch the AI time saved property and aggregate by day + days := make(map[string]*daySummary) + + for _, w := range filtered { + started, _ := time.Parse(api.DateFormat, w.Started) + if started.IsZero() { + started, _ = time.Parse("2006-01-02T15:04:05.000+0000", w.Started) + } + dayKey := started.Format(time.DateOnly) + + if _, ok := days[dayKey]; !ok { + days[dayKey] = &daySummary{ + Date: time.Date(started.Year(), started.Month(), started.Day(), 0, 0, 0, 0, started.Location()), + } + } + + ds := days[dayKey] + ds.Logged += time.Duration(w.TimeSpentSeconds) * time.Second + ds.WorklogIDs++ + + // Fetch AI time saved property + prop, err := jiraClient.GetWorklogProperty(w.IssueID, w.ID, "ai-time-saved") + if err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to fetch AI property for worklog %s: %v\n", w.ID, err) + continue + } + if prop != nil { + var val types.AITimeSavedPropertyValue + if err := json.Unmarshal(prop.Value, &val); err == nil { + ds.AISaved += time.Duration(val.DurationSeconds) * time.Second + } + } + } + + // Build sorted list of day summaries, filling in empty days + summaries := buildDaySummaries(days, start, end) + + // Render the chart + renderChart(summaries, label) +} + +// loadConfig reads the tl configuration file using viper. +func loadConfig(configPath string) error { + if configPath != "" { + viper.SetConfigFile(configPath) + return viper.ReadInConfig() + } + + userConfigDir, err := os.UserConfigDir() + if err != nil { + return fmt.Errorf("could not find user config directory: %w", err) + } + + configDir := filepath.Join(userConfigDir, "tl") + viper.AddConfigPath(configDir) + viper.SetConfigType("yml") + viper.SetConfigName("config") + viper.AutomaticEnv() + + if err := viper.ReadInConfig(); err != nil { + return fmt.Errorf("could not read config file: %w", err) + } + return nil +} + +// buildDaySummaries creates a sorted slice of daySummary with entries for every day in the range. +func buildDaySummaries(days map[string]*daySummary, start, end time.Time) []daySummary { + var summaries []daySummary + + current := time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, start.Location()) + endDay := time.Date(end.Year(), end.Month(), end.Day(), 0, 0, 0, 0, end.Location()) + + for !current.After(endDay) { + key := current.Format(time.DateOnly) + if ds, ok := days[key]; ok { + summaries = append(summaries, *ds) + } else { + summaries = append(summaries, daySummary{Date: current}) + } + current = current.AddDate(0, 0, 1) + } + + sort.Slice(summaries, func(i, j int) bool { + return summaries[i].Date.Before(summaries[j].Date) + }) + + return summaries +} + +// renderChart displays a bar chart of daily time logged vs AI time saved. +func renderChart(summaries []daySummary, label string) { + orange := gchalk.Hex("#ee5622") + cyan := gchalk.Hex("#00bcd4") + dim := gchalk.Dim + bold := gchalk.Bold + + const barMaxWidth = 40 + + // Find max duration for scaling + var maxLogged time.Duration + var totalLogged, totalAISaved time.Duration + + for _, ds := range summaries { + if ds.Logged > maxLogged { + maxLogged = ds.Logged + } + totalLogged += ds.Logged + totalAISaved += ds.AISaved + } + + fmt.Println() + fmt.Println(bold(fmt.Sprintf("AI Time Savings - %s", label))) + fmt.Println() + + for _, ds := range summaries { + dayLabel := ds.Date.Format("Mon 02 Jan") + + if ds.Logged == 0 { + fmt.Printf(" %s %s\n", dayLabel, dim("-")) + continue + } + + // Calculate bar widths + loggedWidth := 0 + aiWidth := 0 + if maxLogged > 0 { + loggedWidth = int(math.Round(float64(ds.Logged) / float64(maxLogged) * barMaxWidth)) + aiWidth = int(math.Round(float64(ds.AISaved) / float64(maxLogged) * barMaxWidth)) + } + if loggedWidth < 1 && ds.Logged > 0 { + loggedWidth = 1 + } + + // The AI bar is drawn within the logged bar to show the proportion + // [=====AI=====|---rest---] + if aiWidth > loggedWidth { + aiWidth = loggedWidth + } + + restWidth := loggedWidth - aiWidth + + bar := cyan(strings.Repeat("█", aiWidth)) + orange(strings.Repeat("█", restWidth)) + padding := strings.Repeat(" ", barMaxWidth-loggedWidth) + + pct := float64(0) + if ds.Logged > 0 { + pct = float64(ds.AISaved) / float64(ds.Logged) * 100 + } + + stats := fmt.Sprintf("%s logged, %s AI saved", + model.FormatDuration(ds.Logged), + model.FormatDuration(ds.AISaved), + ) + if ds.AISaved > 0 { + stats += fmt.Sprintf(" (%s)", bold(fmt.Sprintf("%.0f%%", pct))) + } + + fmt.Printf(" %s %s%s %s\n", dayLabel, bar, padding, stats) + } + + // Footer + fmt.Println() + totalPct := float64(0) + if totalLogged > 0 { + totalPct = float64(totalAISaved) / float64(totalLogged) * 100 + } + + fmt.Printf(" %s %s logged, %s AI saved", + bold("Total "), + bold(model.FormatDuration(totalLogged)), + bold(model.FormatDuration(totalAISaved)), + ) + if totalAISaved > 0 { + fmt.Printf(" (%s)", bold(fmt.Sprintf("%.0f%%", totalPct))) + } + fmt.Println() + fmt.Println() + fmt.Printf(" %s Time logged %s AI time saved\n", orange("█"), cyan("█")) + fmt.Println() +} diff --git a/cmd/aits/command.go b/cmd/aits/command.go new file mode 100644 index 0000000..90b323f --- /dev/null +++ b/cmd/aits/command.go @@ -0,0 +1,67 @@ +package aits + +import ( + "errors" + "fmt" + "strconv" + "time" + + "github.com/spf13/cobra" + "gorm.io/gorm" + + "github.com/previousnext/tl-go/internal/db" + "github.com/previousnext/tl-go/internal/model" +) + +var ( + cmdLong = `Set the AI time saved on a time entry.` + cmdExample = ` + # Set 1 hour of AI time saved on time entry 42 + tl aits 42 1h + + # Set 30 minutes of AI time saved on time entry 7 + tl aits 7 30m` +) + +func NewCommand(r func() db.TimeEntriesInterface) *cobra.Command { + cmd := &cobra.Command{ + Use: "aits ", + Args: cobra.ExactArgs(2), + DisableFlagsInUseLine: true, + Short: "Set AI time saved on a time entry", + Long: cmdLong, + Example: cmdExample, + RunE: func(cmd *cobra.Command, args []string) error { + id, err := strconv.Atoi(args[0]) + if err != nil { + return fmt.Errorf("invalid time entry ID: %s", args[0]) + } + + dur, err := time.ParseDuration(args[1]) + if err != nil { + return fmt.Errorf("invalid duration: %s", args[1]) + } + + entryStorage := r() + entry, err := entryStorage.FindTimeEntry(uint(id)) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "No entry with ID %d\n", id) + return nil + } + return err + } + + entry.AISavedDuration = dur + + if err := entryStorage.UpdateTimeEntry(entry); err != nil { + return err + } + + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Set AI time saved to %s on time entry ID %d\n", model.FormatDuration(dur), entry.ID) + + return nil + }, + } + return cmd +} diff --git a/cmd/aits/command_test.go b/cmd/aits/command_test.go new file mode 100644 index 0000000..ef86c1e --- /dev/null +++ b/cmd/aits/command_test.go @@ -0,0 +1,90 @@ +package aits + +import ( + "bytes" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "gorm.io/gorm" + + "github.com/previousnext/tl-go/internal/db" + dbmocks "github.com/previousnext/tl-go/internal/db/mocks" + "github.com/previousnext/tl-go/internal/model" +) + +func TestAits(t *testing.T) { + entry := &model.TimeEntry{ + Model: gorm.Model{ID: 42}, + IssueKey: "PNX-123", + Duration: 2 * time.Hour, + Issue: &model.Issue{Project: model.Project{}}, + } + var updated *model.TimeEntry + mock := &dbmocks.MockRepository{ + FindTimeEntryFunc: func(id uint) (*model.TimeEntry, error) { + assert.Equal(t, uint(42), id) + return entry, nil + }, + UpdateTimeEntryFunc: func(e *model.TimeEntry) error { + updated = e + return nil + }, + } + + cmd := NewCommand(func() db.TimeEntriesInterface { return mock }) + + var buf bytes.Buffer + cmd.SetOut(&buf) + cmd.SetArgs([]string{"42", "1h"}) + + err := cmd.Execute() + assert.NoError(t, err) + assert.Contains(t, buf.String(), "Set AI time saved to 1h on time entry ID 42") + assert.NotNil(t, updated) + assert.Equal(t, time.Hour, updated.AISavedDuration) +} + +func TestAits_InvalidID_ReturnsError(t *testing.T) { + mock := &dbmocks.MockRepository{} + cmd := NewCommand(func() db.TimeEntriesInterface { return mock }) + + var buf bytes.Buffer + cmd.SetOut(&buf) + cmd.SetArgs([]string{"notanid", "1h"}) + + err := cmd.Execute() + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid time entry ID") +} + +func TestAits_InvalidDuration_ReturnsError(t *testing.T) { + mock := &dbmocks.MockRepository{} + cmd := NewCommand(func() db.TimeEntriesInterface { return mock }) + + var buf bytes.Buffer + cmd.SetOut(&buf) + cmd.SetArgs([]string{"42", "notaduration"}) + + err := cmd.Execute() + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid duration") +} + +func TestAits_EntryNotFound(t *testing.T) { + mock := &dbmocks.MockRepository{ + FindTimeEntryFunc: func(id uint) (*model.TimeEntry, error) { + return nil, gorm.ErrRecordNotFound + }, + } + + cmd := NewCommand(func() db.TimeEntriesInterface { return mock }) + + var buf bytes.Buffer + cmd.SetOut(&buf) + cmd.SetArgs([]string{"99", "1h"}) + + err := cmd.Execute() + assert.NoError(t, err) + assert.Contains(t, buf.String(), "No entry with ID 99") +} diff --git a/cmd/edit/command.go b/cmd/edit/command.go index 006e1ad..3791841 100644 --- a/cmd/edit/command.go +++ b/cmd/edit/command.go @@ -16,7 +16,12 @@ var ( cmdLong = `Edit a time entry` cmdExample = ` # Edit time entry with ID 1 to have a duration of 3 hours and a new description - tl edit 1 --duration 3h --description "Updated description" --date 2024-01-02` + tl edit 1 --duration 3h --description "Updated description" --date 2024-01-02 + + # Edit time entry with ID 1 to indicate 1 hour was saved by AI + tl edit 1 --ai-time-saved 1h + tl edit 1 --aits 1h + tl edit 1 -a 1h` ) func NewCommand(r func() db.TimeEntriesInterface) *cobra.Command { @@ -66,9 +71,20 @@ func NewCommand(r func() db.TimeEntriesInterface) *cobra.Command { } timeEntry.CreatedAt = t } + aiTimeSaved, _ := cmd.Flags().GetString("ai-time-saved") + if aiTimeSaved == "" { + aiTimeSaved, _ = cmd.Flags().GetString("aits") + } + if aiTimeSaved != "" { + aiDur, err := time.ParseDuration(aiTimeSaved) + if err != nil { + return fmt.Errorf("invalid AI time saved duration: %s", aiTimeSaved) + } + timeEntry.AISavedDuration = aiDur + } // If no changes were specified, print an error message and return - if durStr == "" && desc == "" && startDate == "" { - _, _ = fmt.Fprintln(cmd.OutOrStderr(), "No changes specified. Use --time, --description and/or --date to specify changes.") + if durStr == "" && desc == "" && startDate == "" && aiTimeSaved == "" { + _, _ = fmt.Fprintln(cmd.OutOrStderr(), "No changes specified. Use --time, --description, --date and/or --ai-time-saved to specify changes.") return nil } @@ -87,6 +103,9 @@ func NewCommand(r func() db.TimeEntriesInterface) *cobra.Command { cmd.Flags().StringP("time", "t", "", "New duration (e.g. 2h30m)") cmd.Flags().StringP("description", "m", "", "New description") cmd.Flags().StringP("date", "d", "", "The date the time entry should be associated with (e.g. 2024-01-02)") + cmd.Flags().StringP("ai-time-saved", "a", "", "Duration of time saved by AI (e.g. 1h, 30m)") + cmd.Flags().String("aits", "", "Duration of time saved by AI (shorthand for --ai-time-saved)") + _ = cmd.Flags().MarkHidden("aits") return cmd } diff --git a/cmd/list/command.go b/cmd/list/command.go index 225db52..5ebd71f 100644 --- a/cmd/list/command.go +++ b/cmd/list/command.go @@ -74,6 +74,7 @@ func NewCommand(r func() db.TimeEntriesInterface) *cobra.Command { "Issue", "Cat", "Time", + "AI Saved", "Description", "Sent", } @@ -101,6 +102,7 @@ func NewCommand(r func() db.TimeEntriesInterface) *cobra.Command { entry.IssueKey, // Only show plain issue key in table util.AbbreviateProjectCategory(categoryName), model.FormatDuration(entry.Duration), + model.FormatDuration(entry.AISavedDuration), entry.Description, util.FormatBool(entry.Sent), } @@ -116,6 +118,7 @@ func NewCommand(r func() db.TimeEntriesInterface) *cobra.Command { "", util.ApplyHeaderFormatting("Total"), util.ApplyHeaderFormatting(model.FormatDuration(totalDuration)), + "", } t.SetFooters(footer...) @@ -125,6 +128,7 @@ func NewCommand(r func() db.TimeEntriesInterface) *cobra.Command { table.AlignLeft, table.AlignLeft, table.AlignRight, + table.AlignLeft, table.AlignRight, table.AlignLeft, table.AlignLeft, diff --git a/cmd/review/command.go b/cmd/review/command.go index 0db4d26..74dbdfd 100644 --- a/cmd/review/command.go +++ b/cmd/review/command.go @@ -45,6 +45,7 @@ func NewCommand(r func() db.TimeEntriesInterface) *cobra.Command { "Date", "Issue", "Time", + "AI Saved", "Description", } if flagOutput == "wide" { @@ -60,6 +61,7 @@ func NewCommand(r func() db.TimeEntriesInterface) *cobra.Command { entry.CreatedAt.Format(time.DateOnly), entry.IssueKey, model.FormatDuration(entry.Duration), + model.FormatDuration(entry.AISavedDuration), entry.Description, } if flagOutput == "wide" { @@ -75,6 +77,7 @@ func NewCommand(r func() db.TimeEntriesInterface) *cobra.Command { util.ApplyHeaderFormatting("Total"), util.ApplyHeaderFormatting(model.FormatDuration(totalDuration)), "", + "", } if flagOutput == "wide" { footer = append(footer, "", "") @@ -85,7 +88,7 @@ func NewCommand(r func() db.TimeEntriesInterface) *cobra.Command { return fmt.Errorf("error printing table: %w", err) } util.PrintIssueLinks(cmd.OutOrStdout(), entries) - + return nil }, } diff --git a/cmd/root_cmd.go b/cmd/root_cmd.go index 8edf1f6..b350381 100644 --- a/cmd/root_cmd.go +++ b/cmd/root_cmd.go @@ -13,6 +13,7 @@ import ( "github.com/spf13/viper" "github.com/previousnext/tl-go/cmd/add" + "github.com/previousnext/tl-go/cmd/aits" "github.com/previousnext/tl-go/cmd/alias" "github.com/previousnext/tl-go/cmd/delete" "github.com/previousnext/tl-go/cmd/edit" @@ -127,6 +128,7 @@ func init() { } rootCmd.AddCommand(add.NewCommand(timeEntriesFunc, syncFunc, issueStorageFunc)) + rootCmd.AddCommand(aits.NewCommand(timeEntriesFunc)) rootCmd.AddCommand(alias.NewCommand()) rootCmd.AddCommand(delete.NewCommand(timeEntriesFunc)) rootCmd.AddCommand(edit.NewCommand(timeEntriesFunc)) diff --git a/cmd/send/command.go b/cmd/send/command.go index e7031f7..0836164 100644 --- a/cmd/send/command.go +++ b/cmd/send/command.go @@ -47,10 +47,11 @@ func NewCommand(r func() db.TimeEntriesInterface, j func() api.JiraClientInterfa } worklog := types.WorklogRecord{ - IssueKey: timeEntry.IssueKey, - Started: timeEntry.CreatedAt, - Duration: timeEntry.Duration, - Comment: timeEntry.Description, + IssueKey: timeEntry.IssueKey, + Started: timeEntry.CreatedAt, + Duration: timeEntry.Duration, + Comment: timeEntry.Description, + AISavedDuration: timeEntry.AISavedDuration, } err = jiraClient.AddWorkLog(worklog) if err != nil { @@ -81,10 +82,11 @@ func NewCommand(r func() db.TimeEntriesInterface, j func() api.JiraClientInterfa for _, timeEntry := range unsentEntries { worklog := types.WorklogRecord{ - IssueKey: timeEntry.IssueKey, - Started: timeEntry.CreatedAt, - Duration: timeEntry.Duration, - Comment: timeEntry.Description, + IssueKey: timeEntry.IssueKey, + Started: timeEntry.CreatedAt, + Duration: timeEntry.Duration, + Comment: timeEntry.Description, + AISavedDuration: timeEntry.AISavedDuration, } err := jiraClient.AddWorkLog(worklog) if err != nil { diff --git a/cmd/show/command.go b/cmd/show/command.go index 96b4e2e..2b430df 100644 --- a/cmd/show/command.go +++ b/cmd/show/command.go @@ -46,6 +46,7 @@ func NewCommand(r func() db.TimeEntriesInterface) *cobra.Command { {"Summary", entry.Issue.Summary}, {"Project", entry.Issue.Project.Name}, {"Duration", model.FormatDuration(entry.Duration)}, + {"AI Time Saved", model.FormatDuration(entry.AISavedDuration)}, {"Description", entry.Description}, {"Created At", model.FormatDateTime(entry.CreatedAt)}, {"Updated At", model.FormatDateTime(entry.UpdatedAt)}, diff --git a/cmd/timer/stop/command.go b/cmd/timer/stop/command.go index 92836d8..763aba0 100644 --- a/cmd/timer/stop/command.go +++ b/cmd/timer/stop/command.go @@ -3,6 +3,7 @@ package stop import ( "fmt" "strconv" + "time" "github.com/spf13/cobra" @@ -11,7 +12,9 @@ import ( ) func NewCommand(timerService func() service.TimerEntryServiceInterface) *cobra.Command { - return &cobra.Command{ + var aiTimeSavedStr string + + cmd := &cobra.Command{ Use: "stop [timer-id]", Short: "Stop tracking time and save entry", Args: cobra.RangeArgs(0, 1), @@ -26,7 +29,17 @@ func NewCommand(timerService func() service.TimerEntryServiceInterface) *cobra.C id := uint(parsed) timerID = &id } - entry, err := timerService().StopTimeEntry(timerID) + + var stopOpts []service.StopOptions + if aiTimeSavedStr != "" { + aiDur, err := time.ParseDuration(aiTimeSavedStr) + if err != nil { + return fmt.Errorf("invalid AI time saved duration: %s", aiTimeSavedStr) + } + stopOpts = append(stopOpts, service.StopOptions{AISavedDuration: aiDur}) + } + + entry, err := timerService().StopTimeEntry(timerID, stopOpts...) if err != nil { return err } @@ -34,4 +47,10 @@ func NewCommand(timerService func() service.TimerEntryServiceInterface) *cobra.C return nil }, } + + cmd.Flags().StringVarP(&aiTimeSavedStr, "ai-time-saved", "a", "", "Duration of time saved by AI (e.g. 1h, 30m)") + cmd.Flags().StringVar(&aiTimeSavedStr, "aits", "", "Duration of time saved by AI (shorthand for --ai-time-saved)") + _ = cmd.Flags().MarkHidden("aits") + + return cmd } diff --git a/internal/api/jira_client.go b/internal/api/jira_client.go index 4afecbe..9bf8b3c 100644 --- a/internal/api/jira_client.go +++ b/internal/api/jira_client.go @@ -18,6 +18,9 @@ type JiraClientInterface interface { AddWorkLog(worklog types.WorklogRecord) error FetchIssue(issueKey string) (IssueResponse, error) BulkFetchIssues(issueKeys []string) (BulkFetchIssuesResponse, error) + GetUpdatedWorklogIDs(sinceMillis int64) ([]types.WorklogChange, error) + BulkGetWorklogs(ids []int64) ([]types.Worklog, error) + GetWorklogProperty(issueID string, worklogID string, propertyKey string) (*types.EntityPropertyResponse, error) } type HttpClientInterface interface { diff --git a/internal/api/mock/mock_client.go b/internal/api/mock/mock_client.go index 5d92722..5592dd3 100644 --- a/internal/api/mock/mock_client.go +++ b/internal/api/mock/mock_client.go @@ -12,3 +12,15 @@ type JiraClient struct { func (j *JiraClient) AddWorkLog(worklog types.WorklogRecord) error { return nil } + +func (j *JiraClient) GetUpdatedWorklogIDs(sinceMillis int64) ([]types.WorklogChange, error) { + return nil, nil +} + +func (j *JiraClient) BulkGetWorklogs(ids []int64) ([]types.Worklog, error) { + return nil, nil +} + +func (j *JiraClient) GetWorklogProperty(issueID string, worklogID string, propertyKey string) (*types.EntityPropertyResponse, error) { + return nil, nil +} diff --git a/internal/api/types/types.go b/internal/api/types/types.go index 1325a20..4d23930 100644 --- a/internal/api/types/types.go +++ b/internal/api/types/types.go @@ -1,6 +1,7 @@ package types import ( + "encoding/json" "time" ) @@ -11,8 +12,50 @@ type JiraClientParams struct { } type WorklogRecord struct { - IssueKey string - Started time.Time - Duration time.Duration - Comment string + IssueKey string + Started time.Time + Duration time.Duration + Comment string + AISavedDuration time.Duration +} + +// UpdatedWorklogsResponse is the response from GET /rest/api/3/worklog/updated. +type UpdatedWorklogsResponse struct { + Values []WorklogChange `json:"values"` + LastPage bool `json:"lastPage"` + NextPage string `json:"nextPage"` + Since int64 `json:"since"` + Until int64 `json:"until"` +} + +// WorklogChange represents a single changed worklog ID and its update timestamp. +type WorklogChange struct { + WorklogID int64 `json:"worklogId"` + UpdatedTime int64 `json:"updatedTime"` +} + +// Worklog represents a full worklog returned from POST /rest/api/3/worklog/list. +type Worklog struct { + ID string `json:"id"` + IssueID string `json:"issueId"` + Author WorklogAuthor `json:"author"` + Started string `json:"started"` + TimeSpentSeconds int `json:"timeSpentSeconds"` +} + +// WorklogAuthor represents the author of a worklog. +type WorklogAuthor struct { + AccountID string `json:"accountId"` + DisplayName string `json:"displayName"` +} + +// EntityPropertyResponse is the response from GET .../properties/{key}. +type EntityPropertyResponse struct { + Key string `json:"key"` + Value json.RawMessage `json:"value"` +} + +// AITimeSavedPropertyValue is the value stored in the ai-time-saved worklog property. +type AITimeSavedPropertyValue struct { + DurationSeconds int `json:"durationSeconds"` } diff --git a/internal/api/work_logs.go b/internal/api/work_logs.go index 7c7fae5..0767bf8 100644 --- a/internal/api/work_logs.go +++ b/internal/api/work_logs.go @@ -2,9 +2,10 @@ package api import ( "bytes" + "encoding/json" + "errors" "fmt" "net/http" - "text/template" "github.com/previousnext/tl-go/internal/api/types" ) @@ -24,42 +25,167 @@ func (c *JiraClient) AddWorkLog(worklog types.WorklogRecord) error { return nil } +// worklogPayload represents the JSON body sent to the Jira Add Worklog API. +type worklogPayload struct { + Comment worklogComment `json:"comment"` + Started string `json:"started"` + TimeSpentSeconds uint `json:"timeSpentSeconds"` + Properties []entityProperty `json:"properties,omitempty"` +} + +type worklogComment struct { + Type string `json:"type"` + Version int `json:"version"` + Content []worklogParagraph `json:"content"` +} + +type worklogParagraph struct { + Type string `json:"type"` + Content []worklogText `json:"content"` +} + +type worklogText struct { + Type string `json:"type"` + Text string `json:"text"` +} + +type entityProperty struct { + Key string `json:"key"` + Value interface{} `json:"value"` +} + +type aiTimeSavedPropertyValue struct { + DurationSeconds uint `json:"durationSeconds"` +} + func generateWorklogPayload(worklog types.WorklogRecord) (*bytes.Buffer, error) { - payloadTmpl := `{ - "comment": { - "content": [ - { - "content": [ - { - "text": "{{ .comment }}", - "type": "text" - } - ], - "type": "paragraph" - } - ], - "type": "doc", - "version": 1 - }, - "started": "{{ .started }}", - "timeSpentSeconds": {{ .timeSpentSeconds }} -}` + payload := worklogPayload{ + Comment: worklogComment{ + Type: "doc", + Version: 1, + Content: []worklogParagraph{ + { + Type: "paragraph", + Content: []worklogText{ + { + Type: "text", + Text: worklog.Comment, + }, + }, + }, + }, + }, + Started: worklog.Started.Format(DateFormat), + TimeSpentSeconds: uint(worklog.Duration.Seconds()), + } + + if worklog.AISavedDuration > 0 { + payload.Properties = []entityProperty{ + { + Key: "ai-time-saved", + Value: aiTimeSavedPropertyValue{ + DurationSeconds: uint(worklog.AISavedDuration.Seconds()), + }, + }, + } + } + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(payload); err != nil { + return &buf, fmt.Errorf("failed to encode worklog payload: %w", err) + } - t, err := template.New("payload").Parse(payloadTmpl) - if err != nil { - return &buf, fmt.Errorf("failed to parse body template: %w", err) + return &buf, nil +} + +// GetUpdatedWorklogIDs returns all worklog IDs updated since the given timestamp (Unix milliseconds). +// It paginates through all pages automatically. +func (c *JiraClient) GetUpdatedWorklogIDs(sinceMillis int64) ([]types.WorklogChange, error) { + var allChanges []types.WorklogChange + + url := fmt.Sprintf("%s/rest/api/3/worklog/updated?since=%d", c.params.BaseURL, sinceMillis) + for { + respBody, err := c.doRequest(http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("failed to get updated worklogs: %w", err) + } + + var resp types.UpdatedWorklogsResponse + if err := json.NewDecoder(respBody).Decode(&resp); err != nil { + respBody.Close() + return nil, fmt.Errorf("failed to decode updated worklogs response: %w", err) + } + respBody.Close() + + allChanges = append(allChanges, resp.Values...) + + if resp.LastPage { + break + } + url = resp.NextPage + } + + return allChanges, nil +} + +// BulkGetWorklogs fetches full worklog details for a list of worklog IDs. +// The Jira API accepts up to 1000 IDs per request. +func (c *JiraClient) BulkGetWorklogs(ids []int64) ([]types.Worklog, error) { + var allWorklogs []types.Worklog + + // Process in batches of 1000 + for i := 0; i < len(ids); i += 1000 { + end := i + 1000 + if end > len(ids) { + end = len(ids) + } + batch := ids[i:end] + + reqBody := struct { + IDs []int64 `json:"ids"` + }{IDs: batch} + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(reqBody); err != nil { + return nil, fmt.Errorf("failed to encode worklog IDs: %w", err) + } + + url := c.params.BaseURL + "/rest/api/3/worklog/list" + respBody, err := c.doRequest(http.MethodPost, url, &buf) + if err != nil { + return nil, fmt.Errorf("failed to bulk get worklogs: %w", err) + } + + var worklogs []types.Worklog + if err := json.NewDecoder(respBody).Decode(&worklogs); err != nil { + respBody.Close() + return nil, fmt.Errorf("failed to decode worklogs response: %w", err) + } + respBody.Close() + + allWorklogs = append(allWorklogs, worklogs...) } - data := map[string]interface{}{ - "comment": worklog.Comment, - "started": worklog.Started.Format(DateFormat), - "timeSpentSeconds": uint(worklog.Duration.Seconds()), + return allWorklogs, nil +} + +// GetWorklogProperty fetches a single property value from a worklog. +// Returns nil if the property does not exist (404). +func (c *JiraClient) GetWorklogProperty(issueID string, worklogID string, propertyKey string) (*types.EntityPropertyResponse, error) { + url := fmt.Sprintf("%s/rest/api/3/issue/%s/worklog/%s/properties/%s", c.params.BaseURL, issueID, worklogID, propertyKey) + respBody, err := c.doRequest(http.MethodGet, url, nil) + if err != nil { + if errors.Is(err, ErrNotFound) { + return nil, nil + } + return nil, fmt.Errorf("failed to get worklog property: %w", err) } + defer respBody.Close() - if err := t.Execute(&buf, data); err != nil { - return &buf, fmt.Errorf("failed to execute body template: %w", err) + var resp types.EntityPropertyResponse + if err := json.NewDecoder(respBody).Decode(&resp); err != nil { + return nil, fmt.Errorf("failed to decode worklog property response: %w", err) } - return &buf, nil + return &resp, nil } diff --git a/internal/api/work_logs_test.go b/internal/api/work_logs_test.go index fe31cd9..cbb7143 100644 --- a/internal/api/work_logs_test.go +++ b/internal/api/work_logs_test.go @@ -66,8 +66,166 @@ func TestGenerateWorklogPayload(t *testing.T) { payload := buf.String() fmt.Println(payload) assert.NotEmpty(t, payload) - assert.Contains(t, payload, worklog.IssueKey) assert.Contains(t, payload, worklog.Comment) assert.Contains(t, payload, "2024-06-01T10:00:00.000+0000") assert.Contains(t, payload, "7200") // 2 hours in seconds + assert.NotContains(t, payload, "properties") +} + +func TestGenerateWorklogPayload_WithAITimeSaved(t *testing.T) { + worklog := types.WorklogRecord{ + Comment: "Worked on bug fix", + Started: time.Date(2024, 6, 1, 10, 0, 0, 0, time.UTC), + Duration: 2 * time.Hour, + AISavedDuration: 1 * time.Hour, + } + + buf, err := generateWorklogPayload(worklog) + assert.NoError(t, err) + payload := buf.String() + fmt.Println(payload) + assert.NotEmpty(t, payload) + assert.Contains(t, payload, worklog.Comment) + assert.Contains(t, payload, "7200") + assert.Contains(t, payload, `"properties"`) + assert.Contains(t, payload, `"ai-time-saved"`) + assert.Contains(t, payload, `"durationSeconds":3600`) +} + +func TestGenerateWorklogPayload_SpecialCharsInComment(t *testing.T) { + worklog := types.WorklogRecord{ + Comment: `Fixed "bug" with {brackets} & `, + Started: time.Date(2024, 6, 1, 10, 0, 0, 0, time.UTC), + Duration: 30 * time.Minute, + } + + buf, err := generateWorklogPayload(worklog) + assert.NoError(t, err) + payload := buf.String() + // encoding/json properly escapes special characters + assert.Contains(t, payload, `Fixed \"bug\" with {brackets} \u0026 \u003ctags\u003e`) +} + +func TestJiraClient_GetUpdatedWorklogIDs(t *testing.T) { + rt := RoundTripFunc(func(req *http.Request) *http.Response { + assert.Equal(t, "GET", req.Method) + assert.Contains(t, req.URL.String(), "/rest/api/3/worklog/updated?since=1000") + body := `{ + "values": [ + {"worklogId": 101, "updatedTime": 1001}, + {"worklogId": 102, "updatedTime": 1002} + ], + "lastPage": true, + "since": 1000, + "until": 1002 + }` + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewBufferString(body)), + Header: make(http.Header), + } + }) + + httpClient := &http.Client{Transport: rt} + jiraClient := NewJiraClient(httpClient, types.JiraClientParams{ + BaseURL: "https://example.atlassian.net", + Username: "user", + APIToken: "token", + }) + + changes, err := jiraClient.GetUpdatedWorklogIDs(1000) + assert.NoError(t, err) + assert.Len(t, changes, 2) + assert.Equal(t, int64(101), changes[0].WorklogID) + assert.Equal(t, int64(102), changes[1].WorklogID) +} + +func TestJiraClient_BulkGetWorklogs(t *testing.T) { + var capturedBody []byte + rt := RoundTripFunc(func(req *http.Request) *http.Response { + assert.Equal(t, "POST", req.Method) + assert.Contains(t, req.URL.Path, "/rest/api/3/worklog/list") + capturedBody, _ = io.ReadAll(req.Body) + body := `[ + { + "id": "101", + "issueId": "10001", + "author": {"accountId": "abc123", "displayName": "Test User"}, + "started": "2024-06-01T10:00:00.000+0000", + "timeSpentSeconds": 7200 + } + ]` + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewBufferString(body)), + Header: make(http.Header), + } + }) + + httpClient := &http.Client{Transport: rt} + jiraClient := NewJiraClient(httpClient, types.JiraClientParams{ + BaseURL: "https://example.atlassian.net", + Username: "user", + APIToken: "token", + }) + + worklogs, err := jiraClient.BulkGetWorklogs([]int64{101}) + assert.NoError(t, err) + assert.Len(t, worklogs, 1) + assert.Equal(t, "101", worklogs[0].ID) + assert.Equal(t, "10001", worklogs[0].IssueID) + assert.Equal(t, "Test User", worklogs[0].Author.DisplayName) + assert.Equal(t, 7200, worklogs[0].TimeSpentSeconds) + assert.Contains(t, string(capturedBody), "101") +} + +func TestJiraClient_GetWorklogProperty(t *testing.T) { + rt := RoundTripFunc(func(req *http.Request) *http.Response { + assert.Equal(t, "GET", req.Method) + assert.Contains(t, req.URL.Path, "/rest/api/3/issue/10001/worklog/101/properties/ai-time-saved") + body := `{ + "key": "ai-time-saved", + "value": {"durationSeconds": 3600} + }` + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewBufferString(body)), + Header: make(http.Header), + } + }) + + httpClient := &http.Client{Transport: rt} + jiraClient := NewJiraClient(httpClient, types.JiraClientParams{ + BaseURL: "https://example.atlassian.net", + Username: "user", + APIToken: "token", + }) + + prop, err := jiraClient.GetWorklogProperty("10001", "101", "ai-time-saved") + assert.NoError(t, err) + assert.NotNil(t, prop) + assert.Equal(t, "ai-time-saved", prop.Key) + assert.Contains(t, string(prop.Value), "3600") +} + +func TestJiraClient_GetWorklogProperty_NotFound(t *testing.T) { + rt := RoundTripFunc(func(req *http.Request) *http.Response { + body := `{"errorMessages": ["not found"]}` + return &http.Response{ + StatusCode: http.StatusNotFound, + Body: io.NopCloser(bytes.NewBufferString(body)), + Header: make(http.Header), + } + }) + + httpClient := &http.Client{Transport: rt} + jiraClient := NewJiraClient(httpClient, types.JiraClientParams{ + BaseURL: "https://example.atlassian.net", + Username: "user", + APIToken: "token", + }) + + prop, err := jiraClient.GetWorklogProperty("10001", "101", "ai-time-saved") + assert.NoError(t, err) + assert.Nil(t, prop) } diff --git a/internal/model/time_entry.go b/internal/model/time_entry.go index 5a0d03f..5a9e3f9 100644 --- a/internal/model/time_entry.go +++ b/internal/model/time_entry.go @@ -10,12 +10,13 @@ import ( type TimeEntry struct { gorm.Model - IssueKey string `gorm:"index"` - IssueID uint `gorm:"index"` - Issue *Issue `gorm:"foreignkey:IssueID"` - Duration time.Duration // Duration in minutes - Description string - Sent bool + IssueKey string `gorm:"index"` + IssueID uint `gorm:"index"` + Issue *Issue `gorm:"foreignkey:IssueID"` + Duration time.Duration // Duration in minutes + AISavedDuration time.Duration // Duration of time saved by AI + Description string + Sent bool } func FormatDuration(dur time.Duration) string { diff --git a/internal/service/timer_entry.go b/internal/service/timer_entry.go index fe95811..1d5bbf0 100644 --- a/internal/service/timer_entry.go +++ b/internal/service/timer_entry.go @@ -10,11 +10,16 @@ import ( "github.com/previousnext/tl-go/internal/model" ) +// StopOptions holds optional parameters for stopping a timer entry. +type StopOptions struct { + AISavedDuration time.Duration +} + type TimerEntryServiceInterface interface { StartTimeEntry(issueKey string, description *string) error PauseTimeEntry() error ResumeTimerEntry(id *uint) error - StopTimeEntry(id *uint) (*model.TimeEntry, error) + StopTimeEntry(id *uint, opts ...StopOptions) (*model.TimeEntry, error) GetTimerEntry() (*model.TimerEntry, error) GetTimerEntryByID(id uint) (*model.TimerEntry, error) SaveTimerEntry(entry *model.TimerEntry) error @@ -123,7 +128,7 @@ func (s *TimerEntryService) ResumeTimerEntry(id *uint) error { return s.timerEntryStorage.SaveTimerEntry(entry) } -func (s *TimerEntryService) StopTimeEntry(id *uint) (*model.TimeEntry, error) { +func (s *TimerEntryService) StopTimeEntry(id *uint, opts ...StopOptions) (*model.TimeEntry, error) { now := s.now() var entry *model.TimerEntry if id != nil { @@ -184,6 +189,9 @@ func (s *TimerEntryService) StopTimeEntry(id *uint) (*model.TimeEntry, error) { Description: description, Sent: false, } + if len(opts) > 0 { + timeEntry.AISavedDuration = opts[0].AISavedDuration + } if err := s.timeEntryStorage.CreateTimeEntry(timeEntry); err != nil { return nil, err }