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
48 changes: 21 additions & 27 deletions go.mod
Original file line number Diff line number Diff line change
@@ -1,60 +1,54 @@
module github.com/timwehrle/asana

go 1.23.0
go 1.24.7

require (
github.com/AlecAivazis/survey/v2 v2.3.7
github.com/MakeNowJust/heredoc v1.0.0
github.com/spf13/cobra v1.8.1
github.com/spf13/cobra v1.10.1
github.com/zalando/go-keyring v0.2.6
)

require (
dario.cat/mergo v1.0.1
dario.cat/mergo v1.0.2
github.com/google/go-querystring v1.1.0
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
github.com/h2non/gock v1.2.0
github.com/pkg/errors v0.9.1
github.com/rs/xid v1.6.0
github.com/spf13/viper v1.19.0
github.com/stretchr/testify v1.9.0
golang.org/x/oauth2 v0.27.0
github.com/spf13/viper v1.21.0
github.com/stretchr/testify v1.11.1
golang.org/x/oauth2 v0.31.0
)

require (
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/magiconair/properties v1.8.7 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/sagikazarmark/locafero v0.4.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.11.0 // indirect
github.com/spf13/cast v1.6.0 // indirect
github.com/sagikazarmark/locafero v0.12.0 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
go.uber.org/atomic v1.9.0 // indirect
go.uber.org/multierr v1.9.0 // indirect
golang.org/x/exp v0.0.0-20250103183323-7d7fa50e5329 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

require (
al.essio.dev/pkg/shellescape v1.5.1 // indirect
al.essio.dev/pkg/shellescape v1.6.0 // indirect
github.com/danieljoos/wincred v1.2.2 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/mattn/go-colorable v0.1.13
github.com/mattn/go-colorable v0.1.14
github.com/mattn/go-isatty v0.0.20
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b
github.com/spf13/pflag v1.0.5 // indirect
golang.org/x/sys v0.28.0 // indirect
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect
golang.org/x/text v0.21.0 // indirect
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d
github.com/spf13/pflag v1.0.10 // indirect
golang.org/x/sys v0.36.0 // indirect
golang.org/x/term v0.35.0 // indirect
golang.org/x/text v0.29.0 // indirect
)
99 changes: 40 additions & 59 deletions go.sum

Large diffs are not rendered by default.

41 changes: 32 additions & 9 deletions internal/api/asana/tasks.go
Original file line number Diff line number Diff line change
Expand Up @@ -158,12 +158,12 @@ type CreateTaskRequest struct {
Assignee string `json:"assignee,omitempty"` // User to which this task is assigned, or null if the task is unassigned.
Followers []string `json:"followers,omitempty"` // Array of users following this task.

Workspace string `json:"workspace,omitempty"`
Parent string `json:"parent,omitempty"`
Projects []string `json:"projects,omitempty"`
Memberships []*CreateMembership `json:"memberships,omitempty"`
Tags []string `json:"tags,omitempty"`
CustomFields map[string]interface{} `json:"custom_fields,omitempty"`
Workspace string `json:"workspace,omitempty"`
Parent string `json:"parent,omitempty"`
Projects []string `json:"projects,omitempty"`
Memberships []*CreateMembership `json:"memberships,omitempty"`
Tags []string `json:"tags,omitempty"`
CustomFields map[string]any `json:"custom_fields,omitempty"`
}

type CreateMembership struct {
Expand All @@ -174,9 +174,9 @@ type CreateMembership struct {
type UpdateTaskRequest struct {
TaskBase

Assignee string `json:"assignee,omitempty"` // User to which this task is assigned, or null if the task is unassigned.
Followers []string `json:"followers,omitempty"` // Array of users following this task.
CustomFields map[string]interface{} `json:"custom_fields,omitempty"`
Assignee string `json:"assignee,omitempty"` // User to which this task is assigned, or null if the task is unassigned.
Followers []string `json:"followers,omitempty"` // Array of users following this task.
CustomFields map[string]any `json:"custom_fields,omitempty"`
}

// Task is the basic object around which many operations in Asana are
Expand Down Expand Up @@ -603,3 +603,26 @@ func (t *Tag) Tasks(c *Client, opts ...*Options) ([]*Task, *NextPage, error) {
nextPage, err := c.get(fmt.Sprintf("/tags/%s/tasks", t.ID), nil, &results, opts...)
return results, nextPage, err
}

func (t *Task) GetTimeTrackingEntries(c *Client, opts ...*Options) ([]*TimeTrackingEntry, *NextPage, error) {
c.trace("Listing time tracking entries for %q", t.Name)

var results []*TimeTrackingEntry

nextPage, err := c.get(fmt.Sprintf("/tasks/%s/time_tracking_entries", t.ID), nil, &results, opts...)
return results, nextPage, err
}

type CreateTimeTrackingEntryRequest struct {
DurationMinutes int `json:"duration_minutes,omitempty"`
EnteredOn *Date `json:"entered_on,omitempty"`
AttributableTo string `json:"attributable_to,omitempty"`
}

func (t *Task) CreateTimeTrackingEntry(c *Client, request *CreateTimeTrackingEntryRequest, opts ...*Options) (*TimeTrackingEntry, error) {
c.info("Creating time tracking entry for task %q", t.Name)

var result *TimeTrackingEntry
err := c.post(fmt.Sprintf("/tasks/%s/time_tracking_entries", t.ID), request, &result, opts...)
return result, err
}
28 changes: 28 additions & 0 deletions internal/api/asana/time_tracking.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package asana

import "time"

type TimeTrackingEntry struct {
ID string `json:"gid,omitempty"`
ResourceType string `json:"resource_type,omitempty"`
DurationMinutes int `json:"duration_minutes,omitempty"`
EnteredOn *Date `json:"entered_on,omitempty"`
AttributableTo struct {
ID string `json:"gid,omitempty"`
ResourceType string `json:"resource_type,omitempty"`
Name string `json:"name,omitempty"`
}
CreatedBy *User `json:"created_by,omitempty"`
Task *Task `json:"task,omitempty"`
CreatedAt *time.Time `json:"created_at,omitempty"`
ApprovalStatus string `json:"approval_status,omitempty"`
BillableStatus string `json:"billable_status,omitempty"`
Description string `json:"description,omitempty"`
}

func (t *TimeTrackingEntry) Delete(c *Client, opts ...*Options) error {
c.trace("Removing time tracking entry %q", t.ID)

err := c.delete("/time_tracking_entries/"+t.ID, opts...)
return err
}
2 changes: 2 additions & 0 deletions pkg/cmd/root/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"os"

"github.com/timwehrle/asana/pkg/cmd/teams"
"github.com/timwehrle/asana/pkg/cmd/time"

"github.com/MakeNowJust/heredoc"
"github.com/timwehrle/asana/pkg/cmd/tags"
Expand Down Expand Up @@ -70,6 +71,7 @@ func NewCmdRoot(f factory.Factory, buildVersion string) (*cobra.Command, error)
cmd.AddCommand(config.NewCmdConfig(f))
cmd.AddCommand(tags.NewCmdTags(f))
cmd.AddCommand(teams.NewCmdTeams(f))
cmd.AddCommand(time.NewCmdTimer(f))

cmd.SilenceErrors = true
cmd.SilenceUsage = true
Expand Down
147 changes: 147 additions & 0 deletions pkg/cmd/time/create/create.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
package create

import (
"fmt"
"strconv"
"time"

"github.com/spf13/cobra"
"github.com/timwehrle/asana/internal/api/asana"
"github.com/timwehrle/asana/internal/config"
"github.com/timwehrle/asana/internal/prompter"
"github.com/timwehrle/asana/pkg/convert"
"github.com/timwehrle/asana/pkg/factory"
"github.com/timwehrle/asana/pkg/format"
"github.com/timwehrle/asana/pkg/iostreams"
)

type CreateOptions struct {
IO *iostreams.IOStreams
Prompter prompter.Prompter

Config func() (*config.Config, error)
Client func() (*asana.Client, error)
}

func NewCmdCreate(f factory.Factory, runF func(*CreateOptions) error) *cobra.Command {
opts := &CreateOptions{
IO: f.IOStreams,
Prompter: f.Prompter,
Config: f.Config,
Client: f.Client,
}

cmd := &cobra.Command{
Use: "create",
Short: "Create a new time entry for a task",
Long: "Create and log a new time entry on a selected Asana task.",
RunE: func(cmd *cobra.Command, args []string) error {
if runF == nil {
return runCreate(opts)
}

return runF(opts)
},
}

return cmd
}

func runCreate(opts *CreateOptions) error {
io := opts.IO
cs := io.ColorScheme()

client, err := opts.Client()
if err != nil {
return err
}

task, err := selectTask(opts, client)
if err != nil {
return err
}

minutes, err := promptDuration(opts)
if err != nil {
return err
}

date, err := promptDate(opts)
if err != nil {
return err
}

result, err := task.CreateTimeTrackingEntry(client, &asana.CreateTimeTrackingEntryRequest{
DurationMinutes: minutes,
EnteredOn: date,
})
if err != nil {
return fmt.Errorf("failed to create time tracking entry: %w", err)
}

io.Printf("%s Logged %s to %q (%s)\n", cs.SuccessIcon, format.Duration(result.DurationMinutes), task.Name, format.HumanDate(*result.CreatedAt))
return nil
}

func promptDuration(opts *CreateOptions) (int, error) {
minutesStr, err := opts.Prompter.Input("How many minutes do you want to log? (e.g., 30)", "")
if err != nil {
return 0, fmt.Errorf("failed to read duration: %w", err)
}

minutes, err := strconv.Atoi(minutesStr)
if err != nil || minutes <= 0 {
return 0, fmt.Errorf("invalid duration: must be a positive number")
}
return minutes, nil
}

func promptDate(opts *CreateOptions) (*asana.Date, error) {
input, err := opts.Prompter.Input("Enter date [YYYY-MM-DD] or leave empty for today:", "")
if err != nil {
return nil, fmt.Errorf("failed to read date: %w", err)
}

if input == "" {
today := asana.Date(time.Now())
return &today, nil
}

return convert.ToDate(input, time.DateOnly)
}

func selectTask(opts *CreateOptions, c *asana.Client) (*asana.Task, error) {
cfg, err := opts.Config()
if err != nil {
return nil, fmt.Errorf("failed to get config: %w", err)
}

tasks, _, err := c.QueryTasks(&asana.TaskQuery{
Assignee: "me",
Workspace: cfg.Workspace.ID,
CompletedSince: "now",
}, &asana.Options{
Fields: []string{"name", "due_on"},
})
if err != nil {
return nil, fmt.Errorf("failed to query tasks: %w", err)
}

if len(tasks) == 0 {
opts.IO.Println("No tasks found.")
return nil, nil
}

taskNames := format.Tasks(tasks)
index, err := opts.Prompter.Select("Select the task to see the time of:", taskNames)
if err != nil {
return nil, fmt.Errorf("failed to select task: %w", err)
}

selectedTask := tasks[index]
if err := selectedTask.Fetch(c); err != nil {
return nil, fmt.Errorf("failed to fetch task details: %w", err)
}

return selectedTask, nil
}
Loading