diff --git a/README.md b/README.md index fed091d..634748b 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ `punchout` takes the suck out of logging time on JIRA.
- +
Install @@ -49,6 +49,9 @@ jql = "assignee = currentUser() AND updatedDate >= -14d ORDER BY updatedDate DES jira_time_delta_mins = 300 ``` +*Note: `punchout` only supports [on-premise] installations of JIRA for now. I +might add support for cloud installations in the future.* + ### Using command line flags Use `punchout -h` for help. @@ -72,6 +75,16 @@ punchout \ jql='assignee = currentUser() AND updatedDate >= -14d ORDER BY updatedDate DESC' ``` +Screenshots +--- + ++ +
++ +
+ Reference Manual --- @@ -115,3 +128,4 @@ Acknowledgements `punchout` is built using the awesome TUI framework [bubbletea][1]. [1]: https://github.com/charmbracelet/bubbletea +[2]: https://community.atlassian.com/t5/Atlassian-Migration-Program/Product-features-comparison-Atlassian-Cloud-vs-on-premise/ba-p/1918147 diff --git a/go.mod b/go.mod index 57e1bb8..e08b29e 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/charmbracelet/bubbles v0.18.0 github.com/charmbracelet/bubbletea v0.25.0 github.com/charmbracelet/lipgloss v0.10.0 + github.com/dustin/go-humanize v1.0.1 modernc.org/sqlite v1.29.3 ) @@ -15,7 +16,6 @@ require ( github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect - github.com/dustin/go-humanize v1.0.1 // indirect github.com/fatih/structs v1.1.0 // indirect github.com/golang-jwt/jwt/v4 v4.5.0 // indirect github.com/google/go-querystring v1.1.0 // indirect diff --git a/ui/cmds.go b/ui/cmds.go index c534abc..0950d05 100644 --- a/ui/cmds.go +++ b/ui/cmds.go @@ -2,6 +2,8 @@ package ui import ( "database/sql" + "os/exec" + "runtime" "time" jira "github.com/andygrunwald/go-jira/v2/onpremise" @@ -129,7 +131,22 @@ func fetchJIRAIssues(cl *jira.Client, jql string) tea.Cmd { jIssues, err := getIssues(cl, jql) var issues []Issue for _, issue := range jIssues { - issues = append(issues, Issue{issue.Key, issue.Fields.Type.Name, issue.Fields.Summary}) + var assignee string + var totalSecsSpent int + var status string + if issue.Fields != nil { + if issue.Fields.Assignee != nil { + assignee = issue.Fields.Assignee.Name + } + + totalSecsSpent = issue.Fields.AggregateTimeSpent + + if issue.Fields.Status != nil { + status = issue.Fields.Status.Name + + } + } + issues = append(issues, Issue{issue.Key, issue.Fields.Type.Name, issue.Fields.Summary, assignee, status, totalSecsSpent}) } return IssuesFetchedFromJIRAMsg{issues, err} } @@ -147,3 +164,20 @@ func hideHelp(interval time.Duration) tea.Cmd { return HideHelpMsg{} }) } + +func openURLInBrowser(url string) tea.Cmd { + var openCmd string + switch runtime.GOOS { + case "darwin": + openCmd = "open" + default: + openCmd = "xdg-open" + } + c := exec.Command(openCmd, url) + return tea.ExecProcess(c, func(err error) tea.Msg { + if err != nil { + return URLOpenedinBrowserMsg{url: url, err: err} + } + return tea.Msg(URLOpenedinBrowserMsg{url: url}) + }) +} diff --git a/ui/msgs.go b/ui/msgs.go index 294d849..d7b5515 100644 --- a/ui/msgs.go +++ b/ui/msgs.go @@ -52,3 +52,8 @@ type WLAddedOnJIRA struct { entry WorklogEntry err error } + +type URLOpenedinBrowserMsg struct { + url string + err error +} diff --git a/ui/styles.go b/ui/styles.go index 4d96070..309fdd0 100644 --- a/ui/styles.go +++ b/ui/styles.go @@ -5,6 +5,12 @@ import ( "hash/fnv" ) +const ( + ActiveIssueColor = "#d3869b" + IssueStatusColor = "#665c54" + AggTimeSpentColor = "#928374" +) + var ( baseStyle = lipgloss.NewStyle(). PaddingLeft(1). @@ -47,7 +53,7 @@ var ( activeIssueMsgStyle = baseStyle.Copy(). Bold(true). - Foreground(lipgloss.Color("#d3869b")) + Foreground(lipgloss.Color(ActiveIssueColor)) helpTitleStyle = baseStyle.Copy(). Bold(true). @@ -64,11 +70,40 @@ var ( color := issueTypeColors[int(hash)%len(issueTypeColors)] return lipgloss.NewStyle(). PaddingLeft(1). - PaddingRight(1). Foreground(lipgloss.Color("#282828")).Copy(). Bold(true). Align(lipgloss.Center). Width(18). Background(lipgloss.Color(color)) } + + assigneeColors = []string{ + "#ccccff", // Lavender Blue + "#ffa87d", // Light orange + "#7385D8", // Light blue + "#fabd2f", // Bright Yellow + "#00abe5", // Deep Sky + "#d3691e", // Chocolate + } + assigneeStyle = func(assignee string) lipgloss.Style { + h := fnv.New32() + h.Write([]byte(assignee)) + hash := h.Sum32() + + color := assigneeColors[int(hash)%len(assigneeColors)] + + st := lipgloss.NewStyle(). + PaddingLeft(1). + Foreground(lipgloss.Color(color)) + + return st + } + + issueStatusStyle = lipgloss.NewStyle(). + PaddingLeft(1). + Foreground(lipgloss.Color(IssueStatusColor)) + + aggTimeSpentStyle = lipgloss.NewStyle(). + PaddingLeft(2). + Foreground(lipgloss.Color(AggTimeSpentColor)) ) diff --git a/ui/types.go b/ui/types.go index 613de1d..44d7e87 100644 --- a/ui/types.go +++ b/ui/types.go @@ -6,19 +6,44 @@ import ( ) type Issue struct { - IssueKey string - IssueType string - Summary string + IssueKey string + IssueType string + Summary string + Assignee string + Status string + AggSecondsSpent int } func (issue Issue) Title() string { return RightPadTrim(issue.Summary, int(float64(listWidth)*0.8)) } func (issue Issue) Description() string { + // TODO: The padding here is a bit of a mess; make it more readable + var assignee string + var status string + var totalSecsSpent string + issueType := getIssueTypeStyle(issue.IssueType).Render(Trim(issue.IssueType, int(float64(listWidth)*0.2))) - return fmt.Sprintf("%s%s", RightPadTrim(issue.IssueKey, int(float64(listWidth)*0.78)), issueType) + + if issue.Assignee != "" { + assignee = assigneeStyle(issue.Assignee).Render(RightPadTrim("@"+issue.Assignee, int(float64(listWidth)*0.2))) + } else { + assignee = assigneeStyle(issue.Assignee).Render(RightPadTrim("", int(float64(listWidth)*0.2))) + } + + status = issueStatusStyle.Render(RightPadTrim(issue.Status, int(float64(listWidth)*0.2))) + + if issue.AggSecondsSpent > 0 { + if issue.AggSecondsSpent < 3600 { + totalSecsSpent = aggTimeSpentStyle.Render(fmt.Sprintf("%2dm", int(issue.AggSecondsSpent/60))) + } else { + totalSecsSpent = aggTimeSpentStyle.Render(fmt.Sprintf("%2dh", int(issue.AggSecondsSpent/3600))) + } + } + + return fmt.Sprintf("%s%s%s%s%s", RightPadTrim(issue.IssueKey, int(float64(listWidth)*0.3)), status, assignee, issueType, totalSecsSpent) } -func (issue Issue) FilterValue() string { return issue.IssueKey + " : " + issue.Summary } +func (issue Issue) FilterValue() string { return issue.IssueKey } type WorklogEntry struct { Id int diff --git a/ui/update.go b/ui/update.go index 1f2f748..79ae5a9 100644 --- a/ui/update.go +++ b/ui/update.go @@ -1,6 +1,7 @@ package ui import ( + "fmt" "log" "time" @@ -159,7 +160,10 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, fetchLogEntries(m.db)) } case "ctrl+r": - if m.activeView == WorklogView { + switch m.activeView { + case IssueListView: + cmds = append(cmds, fetchJIRAIssues(m.jiraClient, m.jql)) + case WorklogView: cmds = append(cmds, fetchLogEntries(m.db)) } case "ctrl+s": @@ -231,6 +235,11 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "?": m.lastView = m.activeView m.activeView = HelpView + case "ctrl+b": + if m.activeView == IssueListView { + selectedIssue := m.issueList.SelectedItem().FilterValue() + cmds = append(cmds, openURLInBrowser(fmt.Sprintf("%sbrowse/%s", m.jiraClient.BaseURL.String(), selectedIssue))) + } } case tea.WindowSizeMsg: @@ -356,6 +365,10 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case HideHelpMsg: m.showHelpIndicator = false + case URLOpenedinBrowserMsg: + if msg.err != nil { + m.message = fmt.Sprintf("Error opening url: %s", msg.err.Error()) + } } switch m.activeView {