Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[#61511] Implement initial list timeentries command #7

Merged
merged 4 commits into from
Feb 18, 2025
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
2 changes: 2 additions & 0 deletions cmd/list/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ The list can get filtered further.`,

func init() {
initWorkPackagesFlags()
initTimeEntriesFlags()

notificationsCmd.Flags().StringVarP(
&notificationReason,
Expand All @@ -26,6 +27,7 @@ func init() {
workPackagesCmd,
activitiesCmd,
statusCmd,
timeEntriesCmd,
typesCmd,
)
}
50 changes: 50 additions & 0 deletions cmd/list/time_entries.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package list

import (
"github.com/opf/openproject-cli/components/printer"
"github.com/opf/openproject-cli/components/requests"
"github.com/opf/openproject-cli/components/resources"
"github.com/opf/openproject-cli/components/resources/time_entries"
"github.com/opf/openproject-cli/components/resources/time_entries/filters"
"github.com/spf13/cobra"
)

var activeTimeEntryFilters = map[string]resources.Filter{
"user": filters.NewUserFilter(),
}

var timeEntriesCmd = &cobra.Command{
Use: "timeentries",
Short: "Lists time entries",
Long: "Get a list of all time entries.",
Run: listTimeEntries,
}

func listTimeEntries(_ *cobra.Command, _ []string) {
query, err := buildTimeEntriesQuery()
if err != nil {
printer.ErrorText(err.Error())
return
}

if all, err := time_entries.All(query); err == nil {
printer.TimeEntryList(all)
} else {
printer.Error(err)
}
}

func buildTimeEntriesQuery() (requests.Query, error) {
var q requests.Query

for _, filter := range activeTimeEntryFilters {
err := filter.ValidateInput()
if err != nil {
return requests.NewEmptyQuery(), err
}

q = q.Merge(filter.Query())
}

return q, nil
}
13 changes: 13 additions & 0 deletions cmd/list/time_entries_flags.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package list

func initTimeEntriesFlags() {
for _, filter := range activeTimeEntryFilters {
timeEntriesCmd.Flags().StringVarP(
filter.ValuePointer(),
filter.Name(),
filter.ShortHand(),
filter.DefaultValue(),
filter.Usage(),
)
}
}
4 changes: 4 additions & 0 deletions components/paths/paths.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ func Status() string {
return Root() + "/statuses"
}

func TimeEntries() string {
return Root() + "/time_entries"
}

func Types() string {
return Root() + "/types"
}
Expand Down
66 changes: 66 additions & 0 deletions components/printer/time_entries.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package printer

import (
"fmt"
"strings"

"github.com/opf/openproject-cli/components/common"
"github.com/opf/openproject-cli/models"
)

func TimeEntryList(timeEntry []*models.TimeEntry) {
var maxIdLength = 0
var maxActivityLength = 0
var maxProjectLength = 0
for _, t := range timeEntry {
maxIdLength = common.Max(maxIdLength, idLength(t.Id))
maxActivityLength = common.Max(maxActivityLength, len(t.Activity))
maxProjectLength = common.Max(maxProjectLength, len(t.Project))
}

for _, t := range timeEntry {
printTimeEntry(t, maxIdLength, maxActivityLength, maxProjectLength)
}
}

func TimeEntry(timeEntry *models.TimeEntry) {
printTimeEntry(timeEntry, idLength(timeEntry.Id), len(timeEntry.Activity), len(timeEntry.Project))
}

func printTimeEntry(timeEntry *models.TimeEntry, maxIdLength int, maxActivityLength int, maxProjectLength int) {
var parts []string

diff := maxIdLength - idLength(timeEntry.Id)
idStr := fmt.Sprintf("%s#%d", indent(diff), timeEntry.Id)

parts = append(parts, Red(idStr))

if maxActivityLength > 0 {
diff = maxActivityLength - len(timeEntry.Activity)
activityStr := Green(strings.ToUpper(timeEntry.Activity)) + indent(diff)
parts = append(parts, activityStr)
}

parts = append(parts, Cyan(timeEntry.SpentOn.Format("Mon Jan _2")))

hoursStr := fmt.Sprintf("%.2fh", timeEntry.Hours.Hours())
parts = append(parts, hoursStr)

if maxProjectLength > 0 {
diff = maxProjectLength - len(timeEntry.Project)
projectStr := Yellow(timeEntry.Project) + indent(diff)
parts = append(parts, projectStr)
}

parts = append(parts, Cyan(timeEntry.WorkPackage))

if len(timeEntry.Comment) > 0 {
parts = append(parts, timeEntry.Comment)
}

if timeEntry.Ongoing {
parts = append(parts, fmt.Sprintf("(%s)", Yellow("ongoing")))
}

activePrinter.Println(strings.Join(parts, " "))
}
134 changes: 134 additions & 0 deletions components/printer/time_entries_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
package printer_test

import (
"fmt"
"testing"
"time"

"github.com/opf/openproject-cli/components/printer"
"github.com/opf/openproject-cli/models"
)

func TestTimeEntry_Default(t *testing.T) {
testingPrinter.Reset()

timeEntry := models.TimeEntry{
Id: 42,
Comment: "Glad it works!",
Project: "OpenProject",
WorkPackage: "Create Go CLI",
SpentOn: time.Date(2009, time.November, 10, 0, 0, 0, 0, time.UTC),
Hours: time.Hour + 45*time.Minute,
Activity: "Development",
}

expected := fmt.Sprintf("%s %s %s %s %s %s %s\n",
printer.Red("#42"),
printer.Green("DEVELOPMENT"),
printer.Cyan("Tue Nov 10"),
"1.75h",
printer.Yellow("OpenProject"),
printer.Cyan("Create Go CLI"),
"Glad it works!")

printer.TimeEntry(&timeEntry)

if testingPrinter.Result != expected {
t.Errorf("Expected %s, but got %s", expected, testingPrinter.Result)
}
}

func TestTimeEntry_Ongoing(t *testing.T) {
testingPrinter.Reset()

timeEntry := models.TimeEntry{
Id: 42,
Comment: "Glad it works!",
Project: "OpenProject",
WorkPackage: "Create Go CLI",
SpentOn: time.Date(2009, time.November, 10, 0, 0, 0, 0, time.UTC),
Hours: time.Hour + 45*time.Minute,
Activity: "Development",
Ongoing: true,
}

expected := fmt.Sprintf("%s %s %s %s %s %s %s (%s)\n",
printer.Red("#42"),
printer.Green("DEVELOPMENT"),
printer.Cyan("Tue Nov 10"),
"1.75h",
printer.Yellow("OpenProject"),
printer.Cyan("Create Go CLI"),
"Glad it works!",
printer.Yellow("ongoing"))

printer.TimeEntry(&timeEntry)

if testingPrinter.Result != expected {
t.Errorf("Expected %s, but got %s", expected, testingPrinter.Result)
}
}

func TestTimeEntryList(t *testing.T) {
testingPrinter.Reset()

timeEntry := []*models.TimeEntry{
{
Id: 8,
Comment: "Almost lost count!",
Project: "Mobile App",
WorkPackage: "Sprint Meeting",
Hours: 4 * time.Hour,
SpentOn: time.Date(2025, time.February, 4, 0, 0, 0, 0, time.UTC),
Activity: "Accounting",
},
{
Id: 61,
Project: "Frontend App",
WorkPackage: "Rewrite in React",
Hours: 2*time.Hour + 15*time.Minute,
SpentOn: time.Date(2024, time.November, 8, 0, 0, 0, 0, time.UTC),
Activity: "Development",
},
{
Id: 99,
Comment: "",
Project: "Customer Feedback",
WorkPackage: "Stakeholder Testing",
Hours: 35 * time.Minute,
SpentOn: time.Date(2025, time.January, 2, 0, 0, 0, 0, time.UTC),
Activity: "Coordination",
Ongoing: true,
},
}

expected := fmt.Sprintf("%s %s %s %s %s %s %s\n",
printer.Red(" #8"),
printer.Green("ACCOUNTING"),
printer.Cyan("Tue Feb 4"),
"4.00h",
printer.Yellow("Mobile App"),
printer.Cyan("Sprint Meeting"),
"Almost lost count!")
expected += fmt.Sprintf("%s %s %s %s %s %s\n",
printer.Red("#61"),
printer.Green("DEVELOPMENT"),
printer.Cyan("Fri Nov 8"),
"2.25h",
printer.Yellow("Frontend App"),
printer.Cyan("Rewrite in React"))
expected += fmt.Sprintf("%s %s %s %s %s %s (%s)\n",
printer.Red("#99"),
printer.Green("COORDINATION"),
printer.Cyan("Thu Jan 2"),
"0.58h",
printer.Yellow("Customer Feedback"),
printer.Cyan("Stakeholder Testing"),
printer.Yellow("ongoing"))

printer.TimeEntryList(timeEntry)

if testingPrinter.Result != expected {
t.Errorf("Expected \n%s, but got \n%s", expected, testingPrinter.Result)
}
}
68 changes: 68 additions & 0 deletions components/resources/time_entries/filters/user.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package filters

import (
"fmt"
"regexp"
"strings"

"github.com/opf/openproject-cli/components/errors"
"github.com/opf/openproject-cli/components/printer"
"github.com/opf/openproject-cli/components/requests"
)

// validates the value is one or more of the following - separated by commas
// - a valid login (see https://github.com/opf/openproject/blob/dev/app/models/user.rb#L135)
// - a numeric id
// - me
const validValueRegexp = `^([\pL0-9_\-@.+ ]+|[0-9]+|me)(,([\pL0-9_\-@.+ ]+|[0-9]+|me))*$`

type UserFilter struct {
value string
}

func (f *UserFilter) ValuePointer() *string {
return &f.value
}

func (f *UserFilter) Value() string {
return f.value
}

func (f *UserFilter) Name() string {
return "user"
}

func (f *UserFilter) ShortHand() string {
return "u"
}

func (f *UserFilter) Usage() string {
return `User the time entry tracks expenditures for (can be name, ID or 'me')`
}

func (f *UserFilter) ValidateInput() error {
matched, _ := regexp.Match(validValueRegexp, []byte(f.value))
if !matched {
return errors.Custom(fmt.Sprintf("Invalid user value %s.", printer.Yellow(f.value)))
}

return nil
}

func (f *UserFilter) DefaultValue() string {
return "me"
}

func (f *UserFilter) Query() requests.Query {
return requests.NewQuery(nil, []requests.Filter{
{
Operator: "=",
Name: "user",
Values: strings.Split(f.value, ","),
},
})
}

func NewUserFilter() *UserFilter {
return &UserFilter{}
}
19 changes: 19 additions & 0 deletions components/resources/time_entries/functions.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package time_entries

import (
"github.com/opf/openproject-cli/components/parser"
"github.com/opf/openproject-cli/components/paths"
"github.com/opf/openproject-cli/components/requests"
"github.com/opf/openproject-cli/dtos"
"github.com/opf/openproject-cli/models"
)

func All(query requests.Query) ([]*models.TimeEntry, error) {
response, err := requests.Get(paths.TimeEntries(), &query)
if err != nil {
return nil, err
}

element := parser.Parse[dtos.TimeEntryCollectionDto](response)
return element.Convert(), nil
}
Loading
Loading