Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 111 additions & 0 deletions cmd/comment/command.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
package comment

import (
"bufio"
"fmt"
"strings"
"time"

"github.com/spf13/cobra"

"github.com/previousnext/tl-go/internal/db"
"github.com/previousnext/tl-go/internal/model"
"github.com/previousnext/tl-go/internal/util"
)

var (
cmdShort = "Add descriptions to un-commented time entries"
cmdLong = `Cycle through unsent time entries that have no description and prompt
for one for each, so you don't have to edit each entry by ID.

Press Enter to skip an entry, or type "q" to stop.`
cmdExample = `
# Comment all unsent entries without a description
tl comment

# Only comment entries from today
tl comment --date today`
)

func NewCommand(r func() db.TimeEntriesInterface) *cobra.Command {
var flagDate string

cmd := &cobra.Command{
Use: "comment",
Args: cobra.NoArgs,
DisableFlagsInUseLine: true,
Short: cmdShort,
Long: cmdLong,
Example: cmdExample,
RunE: func(cmd *cobra.Command, args []string) error {
storage := r()

entries, err := storage.FindUnsentTimeEntriesWithoutDescription()
if err != nil {
return err
}

// Optionally restrict to a date range.
if flagDate != "" {
start, end, _, err := util.ParseHumanDate(flagDate, time.Now())
if err != nil {
return err
}
entries = filterByRange(entries, start, end)
}

if len(entries) == 0 {
_, _ = fmt.Fprintln(cmd.OutOrStdout(), "No entries without a description found.")
return nil
}

reader := bufio.NewReader(cmd.InOrStdin())
updated := 0

for _, entry := range entries {
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Entry %d | %s | %s | %s\n",
entry.ID,
entry.CreatedAt.Local().Format(time.DateOnly),
entry.IssueKey,
model.FormatDuration(entry.Duration),
)
_, _ = fmt.Fprint(cmd.OutOrStdout(), "Description [skip=Enter, quit=q]: ")

input, _ := reader.ReadString('\n')
input = strings.TrimSpace(input)

if strings.EqualFold(input, "q") {
break
}
if input == "" {
continue
}

entry.Description = input
if err := storage.UpdateTimeEntry(entry); err != nil {
return err
}
updated++
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Updated time entry %d.\n", entry.ID)
}

_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Updated %d of %d entries.\n", updated, len(entries))
return nil
},
}

cmd.Flags().StringVarP(&flagDate, "date", "d", "", "Only comment entries within a date range (YYYY-MM-DD or 'today', 'yesterday', 'this week', 'last week', 'this month', 'last month')")

return cmd
}

func filterByRange(entries []*model.TimeEntry, start, end time.Time) []*model.TimeEntry {
var filtered []*model.TimeEntry
for _, entry := range entries {
created := entry.CreatedAt
if (created.Equal(start) || created.After(start)) && (created.Equal(end) || created.Before(end)) {
filtered = append(filtered, entry)
}
}
return filtered
}
145 changes: 145 additions & 0 deletions cmd/comment/command_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
package comment

import (
"bytes"
"strings"
"testing"
"time"

"github.com/stretchr/testify/assert"

"github.com/previousnext/tl-go/internal/db"
"github.com/previousnext/tl-go/internal/db/mocks"
"github.com/previousnext/tl-go/internal/model"
)

func newRepoFunc(m *mocks.MockRepository) func() db.TimeEntriesInterface {
return func() db.TimeEntriesInterface { return m }
}

func TestComment_UpdatesEntries(t *testing.T) {
updated := map[uint]string{}
e1 := &model.TimeEntry{IssueKey: "PNX-1", Duration: time.Hour}
e1.ID = 1
e2 := &model.TimeEntry{IssueKey: "PNX-2", Duration: time.Hour}
e2.ID = 2
mock := &mocks.MockRepository{
FindUnsentTimeEntriesWithoutDescriptionFunc: func() ([]*model.TimeEntry, error) {
return []*model.TimeEntry{e1, e2}, nil
},
UpdateTimeEntryFunc: func(e *model.TimeEntry) error {
updated[e.ID] = e.Description
return nil
},
}

cmd := NewCommand(newRepoFunc(mock))
var buf bytes.Buffer
cmd.SetOut(&buf)
cmd.SetIn(strings.NewReader("desc1\ndesc2\n"))

err := cmd.Execute()
assert.NoError(t, err)
assert.Equal(t, "desc1", updated[1])
assert.Equal(t, "desc2", updated[2])
assert.Contains(t, buf.String(), "Updated 2 of 2 entries.")
}

func TestComment_EmptyLineSkips(t *testing.T) {
updates := 0
mock := &mocks.MockRepository{
FindUnsentTimeEntriesWithoutDescriptionFunc: func() ([]*model.TimeEntry, error) {
return []*model.TimeEntry{
{IssueKey: "PNX-1", Duration: time.Hour},
{IssueKey: "PNX-2", Duration: time.Hour},
}, nil
},
UpdateTimeEntryFunc: func(e *model.TimeEntry) error {
updates++
return nil
},
}

cmd := NewCommand(newRepoFunc(mock))
var buf bytes.Buffer
cmd.SetOut(&buf)
cmd.SetIn(strings.NewReader("\ndesc2\n"))

err := cmd.Execute()
assert.NoError(t, err)
assert.Equal(t, 1, updates)
assert.Contains(t, buf.String(), "Updated 1 of 2 entries.")
}

func TestComment_QuitStops(t *testing.T) {
updates := 0
mock := &mocks.MockRepository{
FindUnsentTimeEntriesWithoutDescriptionFunc: func() ([]*model.TimeEntry, error) {
return []*model.TimeEntry{
{IssueKey: "PNX-1", Duration: time.Hour},
{IssueKey: "PNX-2", Duration: time.Hour},
}, nil
},
UpdateTimeEntryFunc: func(e *model.TimeEntry) error {
updates++
return nil
},
}

cmd := NewCommand(newRepoFunc(mock))
var buf bytes.Buffer
cmd.SetOut(&buf)
cmd.SetIn(strings.NewReader("q\n"))

err := cmd.Execute()
assert.NoError(t, err)
assert.Equal(t, 0, updates)
assert.Contains(t, buf.String(), "Updated 0 of 2 entries.")
}

func TestComment_NoCandidates(t *testing.T) {
mock := &mocks.MockRepository{
FindUnsentTimeEntriesWithoutDescriptionFunc: func() ([]*model.TimeEntry, error) {
return nil, nil
},
}

cmd := NewCommand(newRepoFunc(mock))
var buf bytes.Buffer
cmd.SetOut(&buf)
cmd.SetIn(strings.NewReader(""))

err := cmd.Execute()
assert.NoError(t, err)
assert.Contains(t, buf.String(), "No entries without a description found.")
}

func TestComment_DateFiltersOutOfRange(t *testing.T) {
updates := 0
inRange := time.Now()
outOfRange := time.Now().AddDate(0, 0, -30)
mock := &mocks.MockRepository{
FindUnsentTimeEntriesWithoutDescriptionFunc: func() ([]*model.TimeEntry, error) {
e1 := &model.TimeEntry{IssueKey: "PNX-1", Duration: time.Hour}
e1.CreatedAt = inRange
e2 := &model.TimeEntry{IssueKey: "PNX-2", Duration: time.Hour}
e2.CreatedAt = outOfRange
return []*model.TimeEntry{e1, e2}, nil
},
UpdateTimeEntryFunc: func(e *model.TimeEntry) error {
updates++
return nil
},
}

cmd := NewCommand(newRepoFunc(mock))
var buf bytes.Buffer
cmd.SetOut(&buf)
cmd.SetIn(strings.NewReader("desc1\n"))
cmd.SetArgs([]string{"--date", "today"})

err := cmd.Execute()
assert.NoError(t, err)
assert.Equal(t, 1, updates)
assert.Contains(t, buf.String(), "Updated 1 of 1 entries.")
}
2 changes: 2 additions & 0 deletions cmd/root_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (

"github.com/previousnext/tl-go/cmd/add"
"github.com/previousnext/tl-go/cmd/alias"
"github.com/previousnext/tl-go/cmd/comment"
"github.com/previousnext/tl-go/cmd/delete"
"github.com/previousnext/tl-go/cmd/edit"
"github.com/previousnext/tl-go/cmd/fetch"
Expand Down Expand Up @@ -128,6 +129,7 @@ func init() {

rootCmd.AddCommand(add.NewCommand(timeEntriesFunc, syncFunc, issueStorageFunc))
rootCmd.AddCommand(alias.NewCommand())
rootCmd.AddCommand(comment.NewCommand(timeEntriesFunc))
rootCmd.AddCommand(delete.NewCommand(timeEntriesFunc))
rootCmd.AddCommand(edit.NewCommand(timeEntriesFunc))
rootCmd.AddCommand(fetch.NewCommand(syncFunc))
Expand Down
20 changes: 14 additions & 6 deletions internal/db/mocks/mock_repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,13 @@ import (
type MockRepository struct {
db.TimeEntriesInterface
db.IssueStorageInterface
Entries []*model.TimeEntry
FindTimeEntriesInRangeFunc func(start, end time.Time) ([]*model.TimeEntry, error)
FindUnsentTimeEntriesFunc func() ([]*model.TimeEntry, error)
FindTimeEntryFunc func(id uint) (*model.TimeEntry, error)
UpdateTimeEntryFunc func(entry *model.TimeEntry) error
GetSummaryByCategoryFunc func(start, end time.Time) ([]db.CategorySummary, error)
Entries []*model.TimeEntry
FindTimeEntriesInRangeFunc func(start, end time.Time) ([]*model.TimeEntry, error)
FindUnsentTimeEntriesFunc func() ([]*model.TimeEntry, error)
FindUnsentTimeEntriesWithoutDescriptionFunc func() ([]*model.TimeEntry, error)
FindTimeEntryFunc func(id uint) (*model.TimeEntry, error)
UpdateTimeEntryFunc func(entry *model.TimeEntry) error
GetSummaryByCategoryFunc func(start, end time.Time) ([]db.CategorySummary, error)
}

var _ db.IssueStorageInterface = (*MockRepository)(nil)
Expand Down Expand Up @@ -65,6 +66,13 @@ func (m *MockRepository) FindUnsentTimeEntries() ([]*model.TimeEntry, error) {
return nil, nil
}

func (m *MockRepository) FindUnsentTimeEntriesWithoutDescription() ([]*model.TimeEntry, error) {
if m.FindUnsentTimeEntriesWithoutDescriptionFunc != nil {
return m.FindUnsentTimeEntriesWithoutDescriptionFunc()
}
return nil, nil
}

func (m *MockRepository) GetSummaryByCategory(start, end time.Time) ([]db.CategorySummary, error) {
if m.GetSummaryByCategoryFunc != nil {
return m.GetSummaryByCategoryFunc(start, end)
Expand Down
13 changes: 13 additions & 0 deletions internal/db/time_entries.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ type TimeEntriesInterface interface {
FindTimeEntry(id uint) (*model.TimeEntry, error)
FindTimeEntriesInRange(start, end time.Time) ([]*model.TimeEntry, error)
FindUnsentTimeEntries() ([]*model.TimeEntry, error)
FindUnsentTimeEntriesWithoutDescription() ([]*model.TimeEntry, error)
FindUniqueIssueKeys() ([]string, error)
UpdateTimeEntry(entry *model.TimeEntry) error
DeleteTimeEntry(id uint) error
Expand Down Expand Up @@ -58,6 +59,18 @@ func (r *Repository) FindUnsentTimeEntries() ([]*model.TimeEntry, error) {
return entries, nil
}

func (r *Repository) FindUnsentTimeEntriesWithoutDescription() ([]*model.TimeEntry, error) {
db := r.openDB()
var entries []*model.TimeEntry
if err := db.Preload("Issue.Project.Category").
Where("sent = ? AND description = ?", false, "").
Order("created_at").
Find(&entries).Error; err != nil {
return nil, err
}
return entries, nil
}

func (r *Repository) FindUniqueIssueKeys() ([]string, error) {
db := r.openDB()
var issueKeys []string
Expand Down
29 changes: 29 additions & 0 deletions internal/db/time_entries_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -172,3 +172,32 @@ func TestGetSummaryByCategory_OrphanEntryNone(t *testing.T) {
assert.Equal(t, "None", summaries[0].CategoryName)
assert.Equal(t, 90*time.Minute, summaries[0].Duration)
}

func TestFindUnsentTimeEntriesWithoutDescription(t *testing.T) {
repo := setupTestRepo(t)
db := repo.openDB()

// Unsent, no description -> included.
createTestEntry(t, db, model.TimeEntry{
IssueKey: "TEST-1",
Duration: time.Hour,
})
// Unsent, with description -> excluded.
createTestEntry(t, db, model.TimeEntry{
IssueKey: "TEST-2",
Duration: time.Hour,
Description: "Already commented",
})
// Sent, no description -> excluded.
createTestEntry(t, db, model.TimeEntry{
IssueKey: "TEST-3",
Duration: time.Hour,
Sent: true,
})

entries, err := repo.FindUnsentTimeEntriesWithoutDescription()

assert.NoError(t, err)
assert.Len(t, entries, 1)
assert.Equal(t, "TEST-1", entries[0].IssueKey)
}
3 changes: 3 additions & 0 deletions internal/service/timer_entry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,9 @@ func (m *mockTimeEntriesStorage) FindTimeEntriesInRange(start, end time.Time) ([
return nil, nil
}
func (m *mockTimeEntriesStorage) FindUnsentTimeEntries() ([]*model.TimeEntry, error) { return nil, nil }
func (m *mockTimeEntriesStorage) FindUnsentTimeEntriesWithoutDescription() ([]*model.TimeEntry, error) {
return nil, nil
}
func (m *mockTimeEntriesStorage) FindUniqueIssueKeys() ([]string, error) { return nil, nil }
func (m *mockTimeEntriesStorage) UpdateTimeEntry(entry *model.TimeEntry) error { return nil }
func (m *mockTimeEntriesStorage) DeleteTimeEntry(id uint) error { return nil }
Expand Down