Skip to content
This repository was archived by the owner on May 20, 2026. It is now read-only.
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
3 changes: 3 additions & 0 deletions internal/api/asana/tasks.go
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,9 @@ type Task struct {
// Read-only. Array of resources referencing tasks that depend on this task.
// The objects contain only the ID of the dependent.
Dependents []*Task `json:"dependents,omitempty"`

// A url that points directly to the object within Asana.
PermalinkURL string `json:"permalink_url,omitempty"`
}

// Fetch loads the full details for this Task
Expand Down
2 changes: 2 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (

type Config struct {
Username string `mapstructure:"username"`
UserID string `mapstructure:"user_id"`
Workspace *asana.Workspace `mapstructure:"workspace"`
CreatedAt time.Time `yaml:"created_at"`
mu sync.RWMutex
Expand Down Expand Up @@ -95,6 +96,7 @@ func (c *Config) Save() error {
}

viper.Set("username", c.Username)
viper.Set("user_id", c.UserID)
viper.Set("workspace", c.Workspace)
viper.Set("created_at", time.Now().Format(time.RFC3339))

Expand Down
1 change: 1 addition & 0 deletions pkg/cmd/auth/login/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,7 @@ func runLogin(opts *LoginOptions) error {

cfg := &config.Config{
Username: user.Name,
UserID: user.ID,
Workspace: selectedWorkspace,
}

Expand Down
138 changes: 111 additions & 27 deletions pkg/cmd/tasks/create/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ package create

import (
"fmt"
"strings"
"time"

"github.com/spf13/cobra"
"github.com/timwehrle/asana/internal/api/asana"
"github.com/timwehrle/asana/internal/config"
Expand All @@ -10,15 +13,18 @@ import (
"github.com/timwehrle/asana/pkg/factory"
"github.com/timwehrle/asana/pkg/format"
"github.com/timwehrle/asana/pkg/iostreams"
"strings"
"time"
)

type CreateOptions struct {
IO *iostreams.IOStreams
Prompter prompter.Prompter
Config func() (*config.Config, error)
Client func() (*asana.Client, error)

Name string
Assignee string
Due string
Description string
}

func NewCmdCreate(f factory.Factory, runF func(*CreateOptions) error) *cobra.Command {
Expand All @@ -41,6 +47,11 @@ func NewCmdCreate(f factory.Factory, runF func(*CreateOptions) error) *cobra.Com
},
}

cmd.Flags().StringVarP(&opts.Name, "name", "n", "", "Task name")
cmd.Flags().StringVarP(&opts.Assignee, "assignee", "a", "", "Assignee name or 'me'")
cmd.Flags().StringVarP(&opts.Due, "due", "d", "", "Due date (YYYY-MM-DD, 'today', 'tomorrow')")
cmd.Flags().StringVarP(&opts.Description, "description", "m", "", "Task description")

return cmd
}

Expand All @@ -56,31 +67,39 @@ func runCreate(opts *CreateOptions) error {
return fmt.Errorf("failed to initialize Asana client: %w", err)
}

// Prompt for task name
name, err := opts.Prompter.Input("Enter task name: ", "")
if err != nil {
return fmt.Errorf("failed to read task name: %w", err)
// Get or prompt for task name
name := opts.Name
if name == "" {
name, err = opts.Prompter.Input("Enter task name: ", "")
if err != nil {
return fmt.Errorf("failed to read task name: %w", err)
}
}
if name == "" {
return fmt.Errorf("task name cannot be empty")
}

// Prompt for assignee
assignee, err := selectAssignee(opts, cfg.Workspace.ID, client)
// Get or prompt for assignee
assignee, err := getOrSelectAssignee(opts, cfg, client)
if err != nil {
return err
}

// Prompt for due date
dueDate, err := getDueDate(opts)
// Get or prompt for due date
dueDate, err := getOrPromptDueDate(opts)
if err != nil {
return err
}

// Prompt for task description
description, err := addDescription(opts)
if err != nil {
return err
description := opts.Description
if description == "" {
shouldPromptForDescription, err := opts.Prompter.Confirm("Add description?", "No")
if err == nil && shouldPromptForDescription {
description, err = addDescription(opts)
}
if err != nil {
return err
}
}

// Prompt for project
Expand Down Expand Up @@ -124,17 +143,70 @@ func runCreate(opts *CreateOptions) error {
return fmt.Errorf("error creating task: %w", err)
}

opts.IO.Printf("%s Created task %q with due date %s\n", cs.SuccessIcon, task.Name, strings.ToLower(format.Date(task.DueOn)))
opts.IO.Printf("%s Created task %s\n", cs.SuccessIcon, cs.Bold(task.Name))
opts.IO.Printf(" %s %s\n", cs.Gray("Assignee:"), assignee.Name)
if task.DueOn != nil {
opts.IO.Printf(" %s %s\n", cs.Gray("Due:"), format.Date(task.DueOn))
}
if task.PermalinkURL != "" {
opts.IO.Printf(" %s %s\n", cs.Gray("URL:"), task.PermalinkURL)
}

return nil
}

func selectAssignee(opts *CreateOptions, workspaceID string, client *asana.Client) (*asana.User, error) {
ws := &asana.Workspace{ID: workspaceID}
func getOrSelectAssignee(opts *CreateOptions, cfg *config.Config, client *asana.Client) (*asana.User, error) {
ws := &asana.Workspace{ID: cfg.Workspace.ID}
users, _, err := ws.Users(client)
if err != nil {
return nil, fmt.Errorf("cannot fetch users: %w", err)
}

// If flag provided
if opts.Assignee != "" {
// Handle 'me' shorthand
if strings.ToLower(opts.Assignee) == "me" {
// If no user ID in config, fetch current user
// This is needed because the user id may not be stored in config yet
if cfg.UserID == "" {
currentUser, err := client.CurrentUser()
if err != nil {
return nil, fmt.Errorf("failed to fetch current user: %w", err)
}
for _, user := range users {
if user.ID == currentUser.ID {
return user, nil
}
}
return nil, fmt.Errorf("could not find current user in workspace")
} else {
for _, user := range users {
if user.ID == cfg.UserID {
return user, nil
}
}
return nil, fmt.Errorf("could not find current user in workspace")
}
}

// Try to match by name
assigneeLower := strings.ToLower(opts.Assignee)
for _, user := range users {
if strings.ToLower(user.Name) == assigneeLower {
return user, nil
}
}

// Try to match by ID
for _, user := range users {
if user.ID == opts.Assignee {
return user, nil
}
}

return nil, fmt.Errorf("assignee %q not found in workspace", opts.Assignee)
}

names := format.MapToStrings(users, func(u *asana.User) string {
return u.Name
})
Expand All @@ -146,19 +218,31 @@ func selectAssignee(opts *CreateOptions, workspaceID string, client *asana.Clien
return users[selected], nil
}

func getDueDate(opts *CreateOptions) (*asana.Date, error) {
input, err := opts.Prompter.Input("Enter due date (YYYY-MM-DD), leave blank for none: ", "")
if err != nil {
return nil, fmt.Errorf("failed to read due date: %w", err)
}

var due *asana.Date
if input != "" {
due, err = convert.ToDate(input, time.DateOnly)
func getOrPromptDueDate(opts *CreateOptions) (*asana.Date, error) {
input := opts.Due
if input == "" {
var err error
input, err = opts.Prompter.Input("Enter due date (YYYY-MM-DD), leave blank for none: ", "")
if err != nil {
return nil, fmt.Errorf("invalid due date %q: %w", input, err)
return nil, fmt.Errorf("failed to read due date: %w", err)
}
}
if input == "" {
return nil, nil
}

now := time.Now()
switch strings.ToLower(input) {
case "today":
return convert.ToDate(now.Format(time.DateOnly), time.DateOnly)
case "tomorrow":
return convert.ToDate(now.AddDate(0, 0, 1).Format(time.DateOnly), time.DateOnly)
}

due, err := convert.ToDate(input, time.DateOnly)
if err != nil {
return nil, fmt.Errorf("invalid due date %q: %w", input, err)
}
return due, nil
}

Expand Down
125 changes: 125 additions & 0 deletions pkg/cmd/tasks/create/create_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
package create

import (
"errors"
"strings"
"testing"
"time"

"github.com/timwehrle/asana/internal/api/asana"
"github.com/timwehrle/asana/internal/config"
"github.com/timwehrle/asana/pkg/factory"
"github.com/timwehrle/asana/pkg/iostreams"
)

func TestNewCmdCreate_RunE(t *testing.T) {
f, _, _ := factory.NewTestFactory()

var sawOpts *CreateOptions
cmd := NewCmdCreate(f, func(opts *CreateOptions) error {
sawOpts = opts
return nil
})

cmd.SetArgs([]string{
"--name", "My Task",
"--assignee", "me",
"--due", "2025-01-01",
"--description", "Test description",
})

if err := cmd.Execute(); err != nil {
t.Fatal(err)
}

if sawOpts == nil {
t.Fatal("runF was never called")
}

if sawOpts.Name != "My Task" {
t.Errorf("Name = %q; want %q", sawOpts.Name, "My Task")
}
if sawOpts.Assignee != "me" {
t.Errorf("Assignee = %q; want %q", sawOpts.Assignee, "me")
}
if sawOpts.Due != "2025-01-01" {
t.Errorf("Due = %q; want %q", sawOpts.Due, "2025-01-01")
}
if sawOpts.Description != "Test description" {
t.Errorf("Description = %q; want %q", sawOpts.Description, "Test description")
}
}

func TestRunCreate_ConfigError(t *testing.T) {
io, _, _, _ := iostreams.Test()

opts := &CreateOptions{
IO: io,
Config: func() (*config.Config, error) {
return nil, errors.New("no config")
},
Client: func() (*asana.Client, error) {
return nil, nil
},
}

err := runCreate(opts)
if err == nil || !strings.Contains(err.Error(), "failed to load config") {
t.Fatalf("expected config error, got %v", err)
}
}

func TestGetOrPromptDueDate(t *testing.T) {
now := time.Now()

tests := []struct {
name string
input string
wantDay string
}{
{
name: "today",
input: "today",
wantDay: now.Format(time.DateOnly),
},
{
name: "tomorrow",
input: "tomorrow",
wantDay: now.AddDate(0, 0, 1).Format(time.DateOnly),
},
{
name: "explicit date",
input: "2025-01-10",
wantDay: "2025-01-10",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
opts := &CreateOptions{Due: tt.input}

got, err := getOrPromptDueDate(opts)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

if got == nil {
t.Fatal("got nil date")
}

gotDay := time.Time(*got).Format(time.DateOnly)
if gotDay != tt.wantDay {
t.Fatalf("date = %v; want %v", gotDay, tt.wantDay)
}
})
}
}

func TestGetOrPromptDueDate_Invalid(t *testing.T) {
opts := &CreateOptions{Due: "not-a-date"}

_, err := getOrPromptDueDate(opts)
if err == nil || !strings.Contains(err.Error(), "invalid due date") {
t.Fatalf("expected invalid-date error, got %v", err)
}
}