diff --git a/app/widget_maker.go b/app/widget_maker.go index ec221574..30dbbd59 100644 --- a/app/widget_maker.go +++ b/app/widget_maker.go @@ -3,6 +3,7 @@ package app import ( "github.com/olebedev/config" "github.com/rivo/tview" + "github.com/wtfutil/wtf/modules/asana" "github.com/wtfutil/wtf/modules/azuredevops" "github.com/wtfutil/wtf/modules/bamboohr" "github.com/wtfutil/wtf/modules/bargraph" @@ -106,6 +107,9 @@ func MakeWidget( case "arpansagovau": settings := arpansagovau.NewSettingsFromYAML(moduleName, moduleConfig, config) widget = arpansagovau.NewWidget(tviewApp, settings) + case "asana": + settings := asana.NewSettingsFromYAML(moduleName, moduleConfig, config) + widget = asana.NewWidget(tviewApp, pages, settings) case "azuredevops": settings := azuredevops.NewSettingsFromYAML(moduleName, moduleConfig, config) widget = azuredevops.NewWidget(tviewApp, pages, settings) diff --git a/go.mod b/go.mod index 27c4e473..9caa1b98 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/wtfutil/wtf go 1.15 require ( + bitbucket.org/mikehouston/asana-go v0.0.0-20201102222432-715318d0343a code.cloudfoundry.org/bytefmt v0.0.0-20190819182555-854d396b647c github.com/Azure/go-autorest v11.1.2+incompatible // indirect github.com/Microsoft/go-winio v0.4.14 // indirect @@ -31,7 +32,6 @@ require ( github.com/gophercloud/gophercloud v0.5.0 // indirect github.com/hekmon/cunits v2.0.1+incompatible // indirect github.com/hekmon/transmissionrpc v0.0.0-20190525133028-1d589625bacd - github.com/imdario/mergo v0.3.8 // indirect github.com/jedib0t/go-pretty v4.3.0+incompatible github.com/jessevdk/go-flags v1.4.0 github.com/lib/pq v1.2.0 // indirect diff --git a/go.sum b/go.sum index 0da8f8ea..4344581b 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +bitbucket.org/mikehouston/asana-go v0.0.0-20201102222432-715318d0343a h1:qH51iOpTres3x2kNb0f2R3ggMpbYCyCvaRrsvdndhvY= +bitbucket.org/mikehouston/asana-go v0.0.0-20201102222432-715318d0343a/go.mod h1:HcP4iCG6i6uVAyX2X7yKOsjbzLFiTfX0EMT20CYn5Ig= cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= @@ -388,8 +390,8 @@ github.com/huandu/xstrings v1.2.0/go.mod h1:DvyZB1rfVYsBIigL8HwpZgxHwXozlTgGqn63 github.com/iancoleman/orderedmap v0.0.0-20190318233801-ac98e3ecb4b0/go.mod h1:N0Wam8K1arqPXNWjMo21EXnBPOPp36vB07FNRdD2geA= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/imdario/mergo v0.0.0-20180119215619-163f41321a19/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA= -github.com/imdario/mergo v0.3.8 h1:CGgOkSJeqMRmt0D9XLWExdT4m4F1vd3FV3VPt+0VxkQ= -github.com/imdario/mergo v0.3.8/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA= +github.com/imdario/mergo v0.3.9 h1:UauaLniWCFHWd+Jp9oCEkTBj8VO/9DKg3PV3VCNMDIg= +github.com/imdario/mergo v0.3.9/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA= github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf/go.mod h1:hyb9oH7vZsitZCiBt0ZvifOrB+qc8PS5IiilCIb87rg= github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= @@ -609,6 +611,8 @@ github.com/rivo/uniseg v0.1.0 h1:+2KBaVoUmb9XzDsrx/Ct0W/EYOSFf/nWTauy++DprtY= github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/rs/xid v1.2.1 h1:mhH9Nq+C1fY2l1XIpgxIiUOfNpRBYH1kKcr+qfKgjRc= +github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ= github.com/rubenv/sql-migrate v0.0.0-20160620083229-6f4757563362/go.mod h1:WS0rl9eEliYI8DPnr3TOwz4439pay+qNgzJoVya/DmY= github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= diff --git a/modules/asana/client.go b/modules/asana/client.go new file mode 100644 index 00000000..8f73769e --- /dev/null +++ b/modules/asana/client.go @@ -0,0 +1,280 @@ +package asana + +import ( + "fmt" + "strings" + "time" + + asana "bitbucket.org/mikehouston/asana-go" +) + +func fetchTasksFromProject(token, projectId, mode string) ([]*TaskItem, error) { + taskItems := []*TaskItem{} + uidToName := make(map[string]string) + + client := asana.NewClientWithAccessToken(token) + + uid, err := getCurrentUserId(client, mode) + if err != nil { + return nil, err + } + + q := &asana.TaskQuery{ + Project: projectId, + } + + fetchedTasks, _, err := getTasksFromAsana(client, q) + if err != nil { + return nil, fmt.Errorf("error fetching tasks: %s", err) + } + + processFetchedTasks(client, &fetchedTasks, &taskItems, &uidToName, mode, projectId, uid) + + return taskItems, nil +} + +func fetchTasksFromProjectSections(token, projectId string, sections []string, mode string) ([]*TaskItem, error) { + taskItems := []*TaskItem{} + uidToName := make(map[string]string) + + client := asana.NewClientWithAccessToken(token) + + uid, err := getCurrentUserId(client, mode) + if err != nil { + return nil, err + } + + p := &asana.Project{ + ID: projectId, + } + + for _, section := range sections { + + sectionId, err := findSection(client, p, section) + if err != nil { + return nil, fmt.Errorf("error fetching tasks: %s", err) + } + + q := &asana.TaskQuery{ + Section: sectionId, + } + + fetchedTasks, _, err := getTasksFromAsana(client, q) + if err != nil { + return nil, fmt.Errorf("error fetching tasks: %s", err) + } + + if len(fetchedTasks) > 0 { + taskItem := &TaskItem{ + name: section, + taskType: TASK_SECTION, + } + + taskItems = append(taskItems, taskItem) + } + + processFetchedTasks(client, &fetchedTasks, &taskItems, &uidToName, mode, projectId, uid) + + } + + return taskItems, nil +} + +func fetchTasksFromWorkspace(token, workspaceId, mode string) ([]*TaskItem, error) { + taskItems := []*TaskItem{} + uidToName := make(map[string]string) + + client := asana.NewClientWithAccessToken(token) + + uid, err := getCurrentUserId(client, mode) + if err != nil { + return nil, err + } + + q := &asana.TaskQuery{ + Workspace: workspaceId, + Assignee: "me", + } + + fetchedTasks, _, err := getTasksFromAsana(client, q) + if err != nil { + return nil, fmt.Errorf("error fetching tasks: %s", err) + } + + processFetchedTasks(client, &fetchedTasks, &taskItems, &uidToName, mode, workspaceId, uid) + + return taskItems, nil + +} + +func toggleTaskCompletionById(token, taskId string) error { + client := asana.NewClientWithAccessToken(token) + + t := &asana.Task{ + ID: taskId, + } + + err := t.Fetch(client) + if err != nil { + return fmt.Errorf("error fetching task: %s", err) + } + + updateReq := &asana.UpdateTaskRequest{} + + if *t.Completed { + f := false + updateReq.Completed = &f + } else { + t := true + updateReq.Completed = &t + } + + err = t.Update(client, updateReq) + if err != nil { + return fmt.Errorf("error updating task: %s", err) + } + + return nil +} + +func processFetchedTasks(client *asana.Client, fetchedTasks *[]*asana.Task, taskItems *[]*TaskItem, uidToName *map[string]string, mode, projectId, uid string) { + + for _, task := range *fetchedTasks { + switch { + case strings.HasSuffix(mode, "_all"): + if task.Assignee != nil { + // Check if we have already looked up this user + if assigneeName, ok := (*uidToName)[task.Assignee.ID]; ok { + task.Assignee.Name = assigneeName + } else { + // We haven't looked up this user before, perform the lookup now + assigneeName, err := getOtherUserEmail(client, task.Assignee.ID) + if err != nil { + task.Assignee.Name = "Error" + } + (*uidToName)[task.Assignee.ID] = assigneeName + task.Assignee.Name = assigneeName + } + } else { + task.Assignee = &asana.User{ + Name: "Unassigned", + } + } + taskItem := buildTaskItem(task, projectId) + (*taskItems) = append((*taskItems), taskItem) + case !strings.HasSuffix(mode, "_all") && task.Assignee != nil && task.Assignee.ID == uid: + taskItem := buildTaskItem(task, projectId) + (*taskItems) = append((*taskItems), taskItem) + } + } +} + +func buildTaskItem(task *asana.Task, projectId string) *TaskItem { + dueOnString := "" + if task.DueOn != nil { + dueOn := time.Time(*task.DueOn) + currentYear, _, _ := time.Now().Date() + if currentYear != dueOn.Year() { + dueOnString = dueOn.Format("Jan 2 2006") + } else { + dueOnString = dueOn.Format("Jan 2") + } + } + + assignString := "" + if task.Assignee != nil { + assignString = task.Assignee.Name + } + + taskItem := &TaskItem{ + name: task.Name, + id: task.ID, + numSubtasks: task.NumSubtasks, + dueOn: dueOnString, + url: fmt.Sprintf("https://app.asana.com/0/%s/%s/f", projectId, task.ID), + taskType: TASK_TYPE, + completed: *task.Completed, + assignee: assignString, + } + + return taskItem + +} + +func getOtherUserEmail(client *asana.Client, uid string) (string, error) { + if uid == "" { + return "", fmt.Errorf("missing uid") + } + + u := &asana.User{ + ID: uid, + } + + err := u.Fetch(client, nil) + if err != nil { + return "", fmt.Errorf("error fetching user: %s", err) + } + + return u.Email, nil +} + +func getCurrentUserId(client *asana.Client, mode string) (string, error) { + if strings.HasSuffix(mode, "_all") { + return "", nil + } + u, err := client.CurrentUser() + if err != nil { + return "", fmt.Errorf("error getting current user: %s", err) + } + + return u.ID, nil +} + +func findSection(client *asana.Client, project *asana.Project, sectionName string) (string, error) { + sectionId := "" + + sections, _, err := project.Sections(client, &asana.Options{ + Limit: 100, + }) + if err != nil { + return "", fmt.Errorf("error getting sections: %s", err) + } + + for _, section := range sections { + if section.Name == sectionName { + sectionId = section.ID + break + } + } + + if sectionId == "" { + return "", fmt.Errorf("we didn't find the section %s", sectionName) + } + + return sectionId, nil +} + +func getTasksFromAsana(client *asana.Client, q *asana.TaskQuery) ([]*asana.Task, bool, error) { + moreTasks := false + + tasks, np, err := client.QueryTasks(q, &asana.Options{ + Limit: 100, + Fields: []string{ + "assignee", + "name", + "num_subtasks", + "due_on", + "completed", + }, + }) + + if err != nil { + return nil, false, fmt.Errorf("error querying tasks: %s", err) + } + + if np != nil { + moreTasks = true + } + + return tasks, moreTasks, nil +} diff --git a/modules/asana/keyboard.go b/modules/asana/keyboard.go new file mode 100644 index 00000000..8b4bbd23 --- /dev/null +++ b/modules/asana/keyboard.go @@ -0,0 +1,20 @@ +package asana + +import "github.com/gdamore/tcell" + +func (widget *Widget) initializeKeyboardControls() { + widget.InitializeHelpTextKeyboardControl(widget.ShowHelp) + widget.InitializeRefreshKeyboardControl(widget.Refresh) + + widget.SetKeyboardChar("j", widget.Next, "Select next task") + widget.SetKeyboardChar("k", widget.Prev, "Select previous task") + widget.SetKeyboardChar("q", widget.Unselect, "Unselect task") + widget.SetKeyboardChar("o", widget.openTask, "Open task in browser") + widget.SetKeyboardChar("x", widget.toggleTaskCompletion, "Toggles the task's completion state") + widget.SetKeyboardChar("?", widget.ShowHelp, "Shows help") + + widget.SetKeyboardKey(tcell.KeyDown, widget.Next, "Select next task") + widget.SetKeyboardKey(tcell.KeyUp, widget.Prev, "Select previous task") + widget.SetKeyboardKey(tcell.KeyEsc, widget.Unselect, "Unselect task") + widget.SetKeyboardKey(tcell.KeyEnter, widget.openTask, "Open task in browser") +} diff --git a/modules/asana/settings.go b/modules/asana/settings.go new file mode 100644 index 00000000..b2605e39 --- /dev/null +++ b/modules/asana/settings.go @@ -0,0 +1,48 @@ +package asana + +import ( + "github.com/olebedev/config" + "github.com/wtfutil/wtf/cfg" + "github.com/wtfutil/wtf/utils" +) + +const ( + defaultFocusable = true + defaultTitle = "Asana" +) + +type Settings struct { + *cfg.Common + + projectId string `help:"The Asana Project ID. If the mode is 'project' or 'project_sections' this is required to known which Asana Project to pull your tasks from" values:"A valid Asana Project ID string" optional:"true"` + + workspaceId string `help:"The Asana Workspace ID. If mode is 'workspace' this is required" values:"A valid Asana Workspace ID string" optional:"true"` + + sections []string `help:"The Asana Section Labels to fetch from the Project. Required if the mode is 'project_sections'" values:"An array of Asana Section Label strings" optional:"true"` + + allUsers bool `help:"Fetch tasks for all users, defaults to false" values:"bool" optional:"true"` + + mode string `help:"What mode to query Asana, 'project', 'project_sections', 'workspace'" values:"A string with either 'project', 'project_sections' or 'workspace'"` + + hideComplete bool `help:"Hide completed tasks, defaults to false" values:"bool" optional:"true"` + + apiKey string `help:"Your Asana Personal Access Token. Leave this blank to use the WTF_ASANA_TOKEN environment variable." values:"Your Asana Personal Access Token as a string" optional:"true"` + + token string +} + +func NewSettingsFromYAML(name string, ymlConfig *config.Config, globalConfig *config.Config) *Settings { + settings := Settings{ + Common: cfg.NewCommonSettingsFromModule(name, defaultTitle, defaultFocusable, ymlConfig, + globalConfig), + projectId: ymlConfig.UString("projectId", ""), + apiKey: ymlConfig.UString("apiKey", ""), + workspaceId: ymlConfig.UString("workspaceId", ""), + sections: utils.ToStrs(ymlConfig.UList("sections")), + allUsers: ymlConfig.UBool("allUsers", false), + mode: ymlConfig.UString("mode", ""), + hideComplete: ymlConfig.UBool("hideComplete", false), + } + + return &settings +} diff --git a/modules/asana/widget.go b/modules/asana/widget.go new file mode 100644 index 00000000..872e67cc --- /dev/null +++ b/modules/asana/widget.go @@ -0,0 +1,259 @@ +package asana + +import ( + "fmt" + "os" + "strings" + "sync" + + "github.com/rivo/tview" + "github.com/wtfutil/wtf/utils" + "github.com/wtfutil/wtf/view" +) + +type TaskType int + +const ( + TASK_TYPE TaskType = iota + TASK_SECTION + TASK_BREAK +) + +type TaskItem struct { + name string + numSubtasks int32 + dueOn string + id string + url string + taskType TaskType + completed bool + assignee string +} + +type Widget struct { + view.ScrollableWidget + + tasks []*TaskItem + + mu sync.Mutex + err error + settings *Settings + tviewApp *tview.Application +} + +func NewWidget(tviewApp *tview.Application, pages *tview.Pages, settings *Settings) *Widget { + widget := &Widget{ + ScrollableWidget: view.NewScrollableWidget(tviewApp, pages, settings.Common), + + tviewApp: tviewApp, + settings: settings, + } + + widget.SetRenderFunction(widget.Render) + widget.initializeKeyboardControls() + + return widget +} + +/* -------------------- Exported Functions -------------------- */ + +func (widget *Widget) Refresh() { + widget.tasks = nil + widget.err = nil + widget.SetItemCount(0) + + widget.mu.Lock() + defer widget.mu.Unlock() + tasks, err := widget.Fetch( + widget.settings.workspaceId, + widget.settings.projectId, + widget.settings.mode, + widget.settings.sections, + widget.settings.allUsers, + ) + if err != nil { + widget.err = err + } else { + widget.tasks = tasks + widget.SetItemCount(len(tasks)) + } + + widget.Render() +} + +func (widget *Widget) Render() { + widget.Redraw(widget.content) +} + +func (widget *Widget) Fetch(workspaceId, projectId, mode string, sections []string, allUsers bool) ([]*TaskItem, error) { + + availableModes := map[string]interface{}{ + "project": nil, + "project_sections": nil, + "workspace": nil, + } + + if _, ok := availableModes[mode]; !ok { + return nil, fmt.Errorf("missing mode, or mode is invalid - please set to project, project_sections or workspace") + } + + if widget.settings.apiKey != "" { + widget.settings.token = widget.settings.apiKey + } else { + widget.settings.token = os.Getenv("WTF_ASANA_TOKEN") + } + + if widget.settings.token == "" { + return nil, fmt.Errorf("missing environment variable token or apikey config") + } + + subMode := mode + if allUsers && mode != "workspace" { + subMode += "_all" + } + + if projectId == "" && strings.HasPrefix(subMode, "project") { + return nil, fmt.Errorf("missing project id") + } + + if workspaceId == "" && subMode == "workspace" { + return nil, fmt.Errorf("missing workspace id") + } + + var tasks []*TaskItem + var err error + + switch { + case strings.HasPrefix(subMode, "project_sections"): + tasks, err = fetchTasksFromProjectSections(widget.settings.token, projectId, sections, subMode) + case strings.HasPrefix(subMode, "project"): + tasks, err = fetchTasksFromProject(widget.settings.token, projectId, subMode) + case subMode == "workspace": + tasks, err = fetchTasksFromWorkspace(widget.settings.token, workspaceId, subMode) + default: + err = fmt.Errorf("no mode found") + } + + if err != nil { + return nil, err + } + + return tasks, nil + +} + +/* -------------------- Unexported Functions -------------------- */ + +func (widget *Widget) content() (string, string, bool) { + + title := widget.CommonSettings().Title + if widget.err != nil { + return title, widget.err.Error(), true + } + + data := widget.tasks + if len(data) == 0 { + return title, "No data", false + } + + var str string + + for idx, taskItem := range data { + switch { + case taskItem.taskType == TASK_TYPE: + if widget.settings.hideComplete && taskItem.completed { + continue + } + + rowColor := widget.RowColor(idx) + + completed := "[ []" + if taskItem.completed { + completed = "[x[]" + } + + row := "" + + if widget.settings.allUsers && taskItem.assignee != "" { + row = fmt.Sprintf( + "[%s] %s %s: %s", + rowColor, + completed, + taskItem.assignee, + taskItem.name, + ) + } else { + row = fmt.Sprintf( + "[%s] %s %s", + rowColor, + completed, + taskItem.name, + ) + } + + if taskItem.numSubtasks > 0 { + row += fmt.Sprintf(" (%d)", taskItem.numSubtasks) + } + + if taskItem.dueOn != "" { + row += fmt.Sprintf(" due: %s", taskItem.dueOn) + } + + row += " [white]" + + str += utils.HighlightableHelper(widget.View, row, idx, len(taskItem.name)) + + case taskItem.taskType == TASK_SECTION: + if idx > 1 { + row := "[white] " + + str += utils.HighlightableHelper(widget.View, row, idx, len(taskItem.name)) + } + row := fmt.Sprintf( + "[white] %s [white]", + taskItem.name, + ) + + str += utils.HighlightableHelper(widget.View, row, idx, len(taskItem.name)) + + row = "[white] " + + str += utils.HighlightableHelper(widget.View, row, idx, len(taskItem.name)) + + } + + } + + return title, str, false + +} + +func (widget *Widget) openTask() { + sel := widget.GetSelected() + + if sel >= 0 && widget.tasks != nil && sel < len(widget.tasks) { + task := widget.tasks[sel] + if task.taskType == TASK_TYPE && task.url != "" { + utils.OpenFile(task.url) + } + } +} + +func (widget *Widget) toggleTaskCompletion() { + sel := widget.GetSelected() + + if sel >= 0 && widget.tasks != nil && sel < len(widget.tasks) { + task := widget.tasks[sel] + if task.taskType == TASK_TYPE { + widget.mu.Lock() + + err := toggleTaskCompletionById(widget.settings.token, task.id) + if err != nil { + widget.err = err + } + + widget.mu.Unlock() + widget.Refresh() + } + } +}