├── version.go ├── screenshot.png ├── .gitignore ├── status_bar_fragment.go ├── .travis.yml ├── examples └── templates │ ├── voter_list │ ├── colour_list │ └── view ├── jira-ui └── main.go ├── ticket_show_page_test.go ├── base_input_box.go ├── status_bar.go ├── password_input_box.go ├── Dockerfile ├── sorted_map.go ├── CHANGELOG.md ├── go.mod ├── help_page.go ├── CODE_OF_CONDUCT.md ├── editbox.go ├── base_list_page.go ├── sort_order_page.go ├── label_list_page.go ├── utils_test.go ├── command_bar.go ├── ticket_list_page.go ├── go.sum ├── query_list_page.go ├── templates.go ├── README.md ├── scrollablelist.go ├── ticket_show_page.go ├── ui_controls.go ├── run.go ├── command_bar_fragment.go ├── utils.go └── LICENSE /version.go: -------------------------------------------------------------------------------- 1 | package jiraui 2 | 3 | const ( 4 | VERSION = "0.4.1" 5 | ) 6 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mikepea/go-jira-ui/HEAD/screenshot.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | jira-ui 2 | src/ 3 | bin/ 4 | pkg/ 5 | .jira.d 6 | *.swp 7 | err 8 | main 9 | -------------------------------------------------------------------------------- /status_bar_fragment.go: -------------------------------------------------------------------------------- 1 | package jiraui 2 | 3 | type StatusBarFragment struct { 4 | statusBar *StatusBar 5 | } 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | 3 | env: 4 | - GO111MODULE=on 5 | 6 | go: 7 | - '1.12' 8 | - '1.13' 9 | - '1.14' 10 | - 'master' 11 | -------------------------------------------------------------------------------- /examples/templates/voter_list: -------------------------------------------------------------------------------- 1 | {{ range .issues }}{{ .key | printf "%-12s"}} votes:{{.fields.votes.votes}} {{ dateFormat "2006-01-02" .fields.created }} {{ .fields.summary | printf "%-75s"}} 2 | {{ end }} 3 | -------------------------------------------------------------------------------- /examples/templates/colour_list: -------------------------------------------------------------------------------- 1 | {{ range .issues }}[{{ .key | printf "%-12s"}}](fg-red) [{{ dateFormat "2006-01-02" .fields.created }}](fg-blue)/[{{ dateFormat "2006-01-02T15:04" .fields.updated }}](fg-green) {{ .fields.summary | printf "%-75s"}} 2 | {{ end }} 3 | -------------------------------------------------------------------------------- /jira-ui/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os/exec" 6 | 7 | "github.com/mikepea/go-jira-ui" 8 | ) 9 | 10 | func resetTTY() { 11 | cmd := exec.Command("reset") 12 | _ = cmd.Run() 13 | fmt.Println() 14 | } 15 | 16 | func main() { 17 | defer resetTTY() 18 | jiraui.Run() 19 | } 20 | -------------------------------------------------------------------------------- /ticket_show_page_test.go: -------------------------------------------------------------------------------- 1 | package jiraui 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestTicketIdSetting(t *testing.T) { 8 | sp := new(TicketShowPage) 9 | if sp.TicketId != "" { 10 | t.Fatalf("sp.TicketId: expected %q, got %q", "", sp.TicketId) 11 | } 12 | 13 | sp.TicketId = "ABC-123" 14 | if sp.TicketId != "ABC-123" { 15 | t.Fatalf("sp.TicketId: expected %q, got %q", "ABC-123", sp.TicketId) 16 | } 17 | 18 | if sp.Id() != "ABC-123" { 19 | t.Fatalf("sp.Id: expected %q, got %q", "ABC-123", sp.Id()) 20 | } 21 | 22 | } 23 | -------------------------------------------------------------------------------- /base_input_box.go: -------------------------------------------------------------------------------- 1 | package jiraui 2 | 3 | import ( 4 | ui "gopkg.in/gizak/termui.v2" 5 | ) 6 | 7 | type BaseInputBox struct { 8 | EditBox 9 | uiList *ScrollableList 10 | } 11 | 12 | func (p *BaseInputBox) Update() { 13 | ls := p.uiList 14 | ui.Render(ls) 15 | } 16 | 17 | func (p *BaseInputBox) Id() string { 18 | return "" 19 | } 20 | 21 | func (p *BaseInputBox) Create() { 22 | ls := NewScrollableList() 23 | var strs []string 24 | p.uiList = ls 25 | ls.Items = strs 26 | ls.ItemFgColor = ui.ColorGreen 27 | ls.BorderFg = ui.ColorRed 28 | ls.Height = 1 29 | ls.Width = 30 30 | ls.X = ui.TermWidth()/2 - ls.Width/2 31 | ls.Y = ui.TermHeight()/2 - ls.Height/2 32 | p.Update() 33 | } 34 | -------------------------------------------------------------------------------- /status_bar.go: -------------------------------------------------------------------------------- 1 | package jiraui 2 | 3 | import ( 4 | ui "gopkg.in/gizak/termui.v2" 5 | ) 6 | 7 | type StatusBar struct { 8 | uiList *ui.List 9 | lines []string 10 | } 11 | 12 | func (p *StatusBar) StatusLines() []string { 13 | return p.lines 14 | } 15 | 16 | func (p *StatusBar) Update() { 17 | ls := p.uiList 18 | ls.Items = p.StatusLines() 19 | ui.Render(ls) 20 | } 21 | 22 | func (p *StatusBar) Create() { 23 | ls := ui.NewList() 24 | p.uiList = ls 25 | ls.ItemFgColor = ui.ColorWhite 26 | ls.ItemBgColor = ui.ColorRed 27 | ls.Bg = ui.ColorRed 28 | ls.Border = false 29 | ls.Height = 1 30 | ls.Width = ui.TermWidth() 31 | ls.X = 0 32 | ls.Y = ui.TermHeight() - 2 33 | p.Update() 34 | } 35 | -------------------------------------------------------------------------------- /password_input_box.go: -------------------------------------------------------------------------------- 1 | package jiraui 2 | 3 | import ( 4 | "strings" 5 | 6 | ui "gopkg.in/gizak/termui.v2" 7 | ) 8 | 9 | type PasswordInputBox struct { 10 | BaseInputBox 11 | } 12 | 13 | func (p *PasswordInputBox) Update() { 14 | ls := p.uiList 15 | ls.Items = strings.Split(string(p.text), "\n") 16 | ui.Render(ls) 17 | } 18 | 19 | func (p *PasswordInputBox) Create() { 20 | ls := NewScrollableList() 21 | p.uiList = ls 22 | var strs []string 23 | ls.Items = strs 24 | ls.ItemFgColor = ui.ColorGreen 25 | ls.BorderLabel = "Enter Password:" 26 | ls.BorderFg = ui.ColorRed 27 | ls.Height = 3 28 | ls.Width = 30 29 | ls.X = ui.TermWidth()/2 - ls.Width/2 30 | ls.Y = ui.TermHeight()/2 - ls.Height/2 31 | p.Update() 32 | } 33 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # SET GO AND ALPINE VERSIONS 2 | ARG GO_VER=1.15.3 3 | ARG ALPINE_VER=3.12 4 | 5 | # COPY AND BUILD SOURCE 6 | FROM golang:${GO_VER}-alpine${ALPINE_VER} AS builder 7 | COPY . /src 8 | RUN cd /src/jira-ui && go build -o /tmp/jira-ui main.go 9 | 10 | ########## ########## ########## 11 | 12 | # START A LEAN CONTAINER 13 | FROM alpine:${ALPINE_VER} 14 | 15 | # COPY ARTIFACT FROM BUILDER CONTAINER 16 | COPY --from=builder /tmp/jira-ui /usr/local/bin/jira-ui 17 | 18 | # INSTALL EDITORS 19 | RUN apk add --no-cache vim nano 20 | 21 | # SETUP UNDERPRIVILEGED USER AND LINK CONFIG 22 | RUN adduser -D jira && \ 23 | ln -s /config /home/jira/.jira.d && \ 24 | chown -R jira:jira /home/jira 25 | USER jira 26 | 27 | ENTRYPOINT ["/usr/local/bin/jira-ui"] 28 | -------------------------------------------------------------------------------- /sorted_map.go: -------------------------------------------------------------------------------- 1 | package jiraui 2 | 3 | // sort a map's keys in descending order of its values. 4 | // lifted from http://play.golang.org/p/x4CoUsJ5tK 5 | 6 | import "sort" 7 | 8 | type sortedMap struct { 9 | m map[string]int 10 | s []string 11 | } 12 | 13 | func (sm *sortedMap) Len() int { 14 | return len(sm.m) 15 | } 16 | 17 | func (sm *sortedMap) Less(i, j int) bool { 18 | return sm.m[sm.s[i]] > sm.m[sm.s[j]] 19 | } 20 | 21 | func (sm *sortedMap) Swap(i, j int) { 22 | sm.s[i], sm.s[j] = sm.s[j], sm.s[i] 23 | } 24 | 25 | func sortedKeys(m map[string]int) []string { 26 | sm := new(sortedMap) 27 | sm.m = m 28 | sm.s = make([]string, len(m)) 29 | i := 0 30 | for key, _ := range m { 31 | sm.s[i] = key 32 | i++ 33 | } 34 | sort.Sort(sm) 35 | return sm.s 36 | } 37 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========= 3 | 4 | v0.4.0 5 | ------ 6 | 7 | * #3: Implement 'NextTicket' and 'PrevTicket' 8 | * Add :create {project} {summary} command 9 | * #31 - use age template function 10 | * #8 - Allow breaking out of the UI to run external commands (eg vim) 11 | * Add various quick search commands 12 | * Add 'previousPages history', to make 'q' less painful 13 | * Swtich to use ScrollableList base interface widget 14 | 15 | v0.3.2 16 | ------ 17 | 18 | * Bugfix: allow 2-character project names for findTicketIdInString [Aneesh Goel] 19 | 20 | v0.3.1 21 | ------ 22 | 23 | * Bugfix: incorrect logging level causing display error 24 | 25 | v0.3.0 26 | ------ 27 | 28 | * Search history and Command History work across all pages 29 | 30 | v0.2.0 31 | ------ 32 | 33 | * Add :view TICKET command 34 | * Add :query JQL commands 35 | 36 | v0.1.1 37 | ------ 38 | 39 | * Fix bug: refresh can make cachedResults shorter, causing slice index panic 40 | 41 | v0.1.0 42 | ------ 43 | 44 | * Initial release 45 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/mikepea/go-jira-ui 2 | 3 | go 1.14 4 | 5 | require ( 6 | github.com/coryb/optigo v0.0.0-20170510052407-6f3f720fe67b 7 | github.com/guelfey/go.dbus v0.0.0-20131113121618-f6a3a2366cc3 // indirect 8 | github.com/howeyc/gopass v0.0.0-20190910152052-7cb4b85ec19c // indirect 9 | github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect 10 | github.com/maruel/panicparse v1.5.0 // indirect 11 | github.com/mattn/go-runewidth v0.0.9 // indirect 12 | github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect 13 | github.com/mitchellh/go-wordwrap v1.0.0 14 | github.com/nsf/termbox-go v0.0.0-20200418040025-38ba6e5628f1 // indirect 15 | github.com/op/go-logging v0.0.0-20160315200505-970db520ece7 16 | github.com/tmc/keyring v0.0.0-20171121202319-839169085ae1 // indirect 17 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 // indirect 18 | gopkg.in/Netflix-Skunkworks/go-jira.v0 v0.1.15 19 | gopkg.in/coryb/yaml.v2 v2.0.0 20 | gopkg.in/gizak/termui.v2 v2.3.0 21 | gopkg.in/op/go-logging.v1 v1.0.0-20160211212156-b2cb9fa56473 // indirect 22 | ) 23 | -------------------------------------------------------------------------------- /examples/templates/view: -------------------------------------------------------------------------------- 1 | issue: [{{ .key }}](fg-red) 2 | summary: [{{ .fields.summary }}](fg-blue) 3 | 4 | self: {{ .self }} 5 | browse: https://jira.example.com/browse/{{ .key }} 6 | priority: {{ .fields.priority.name }} 7 | status: {{ .fields.status.name }} 8 | votes: {{ .fields.votes.votes }} 9 | created: {{ .fields.created }} 10 | updated: {{ .fields.updated }} 11 | assignee: {{ if .fields.assignee }}{{ .fields.assignee.name }}{{end}} 12 | reporter: {{ if .fields.reporter }}{{ .fields.reporter.name }}{{end}} 13 | issuetype: {{ .fields.issuetype.name }} 14 | {{if eq .fields.issuetype.name "Epic" }}epic_links: [](fg-red){{end}} 15 | {{if .fields.customfield_10001 }}epic: [{{ .fields.customfield_10001 }}](fg-red){{end}} 16 | {{if .fields.parent }}parent: [{{ .fields.parent.key }}](fg-red) -- {{ .fields.parent.fields.summary }}{{end}} 17 | subtasks: 18 | {{ range .fields.subtasks }} - [{{ .key }}](fg-red)[{{.fields.status.name}}] -- {{.fields.summary}} 19 | {{end}} 20 | 21 | [labels:](fg-green){{ range .fields.labels }} {{ . }}{{end}} 22 | [components:](fg-green){{ range .fields.components }} {{ .name }}{{end}} 23 | [watchers:](fg-green){{ range .fields.customfield_10304 }} {{ .name }}{{end}} 24 | [blockers:](fg-green) 25 | {{ range .fields.issuelinks }}{{if .outwardIssue}} - [{{ .outwardIssue.key }}](fg-red)[{{.outwardIssue.fields.status.name}} -- {{.outwardIssue.fields.summary}} 26 | {{end}}{{end}} 27 | [depends:](fg-green) 28 | {{ range .fields.issuelinks }}{{if .inwardIssue}} - [{{ .inwardIssue.key }}](fg-red)[{{.inwardIssue.fields.status.name}}] -- {{.inwardIssue.fields.summary}} 29 | {{end}}{{end}} 30 | 31 | [description:](fg-green) 32 | 33 | {{ or .fields.description "" | indent 2 }} 34 | 35 | [comments:](fg-green) 36 | 37 | {{ range .fields.comment.comments }} 38 | - [{{.author.name}} at {{.created}}](fg-blue) 39 | {{ or .body "" | indent 4}} 40 | 41 | {{end}} 42 | -------------------------------------------------------------------------------- /help_page.go: -------------------------------------------------------------------------------- 1 | package jiraui 2 | 3 | import ( 4 | ui "gopkg.in/gizak/termui.v2" 5 | ) 6 | 7 | type HelpPage struct { 8 | BaseListPage 9 | CommandBarFragment 10 | StatusBarFragment 11 | } 12 | 13 | func (p *HelpPage) Search() { 14 | s := p.ActiveSearch 15 | n := len(p.cachedResults) 16 | if s.command == "" { 17 | return 18 | } 19 | increment := 1 20 | if s.directionUp { 21 | increment = -1 22 | } 23 | // we use modulo here so we can loop through every line. 24 | // adding 'n' means we never have '-1 % n'. 25 | startLine := (p.uiList.Cursor + n + increment) % n 26 | for i := startLine; i != p.uiList.Cursor; i = (i + increment + n) % n { 27 | if s.re.MatchString(p.cachedResults[i]) { 28 | p.uiList.SetCursorLine(i) 29 | p.Update() 30 | break 31 | } 32 | } 33 | } 34 | 35 | func (p *HelpPage) GoBack() { 36 | currentPage, previousPages = previousPages[len(previousPages)-1], previousPages[:len(previousPages)-1] 37 | changePage() 38 | } 39 | 40 | func (p *HelpPage) Refresh() { 41 | pDeref := &p 42 | q := *pDeref 43 | q.cachedResults = make([]string, 0) 44 | helpPage = q 45 | currentPage = helpPage 46 | changePage() 47 | q.Create() 48 | } 49 | 50 | func (p *HelpPage) Update() { 51 | ls := p.uiList 52 | ui.Render(ls) 53 | p.statusBar.Update() 54 | p.commandBar.Update() 55 | } 56 | 57 | func (p *HelpPage) Create() { 58 | ui.Clear() 59 | ls := NewScrollableList() 60 | p.uiList = ls 61 | if p.statusBar == nil { 62 | p.statusBar = new(StatusBar) 63 | } 64 | if p.commandBar == nil { 65 | p.commandBar = commandBar 66 | } 67 | if len(p.cachedResults) == 0 { 68 | p.cachedResults = HelpTextAsStrings(nil, "jira_ui_help") 69 | } 70 | ls.Items = p.cachedResults 71 | ls.ItemFgColor = ui.ColorYellow 72 | ls.BorderLabel = "Help" 73 | ls.Height = ui.TermHeight() - 2 74 | ls.Width = ui.TermWidth() 75 | ls.Y = 0 76 | p.statusBar.Create() 77 | p.commandBar.Create() 78 | p.Update() 79 | } 80 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | Contributor Code of Conduct 2 | --------------------------- 3 | 4 | As contributors and maintainers of this project, and in the interest of fostering 5 | an open and welcoming community, we pledge to respect all people who contribute 6 | through reporting issues, posting feature requests, updating documentation, submitting 7 | pull requests or patches, and other activities. 8 | 9 | We are committed to making participation in this project a harassment-free experience 10 | for everyone, regardless of level of experience, gender, gender identity and expression, 11 | sexual orientation, disability, personal appearance, body size, race, ethnicity, age, 12 | religion, or nationality. 13 | 14 | Examples of unacceptable behavior by participants include: 15 | 16 | * The use of sexualized language or imagery 17 | * Personal attacks 18 | * Trolling or insulting/derogatory comments 19 | * Public or private harassment 20 | * Publishing other's private information, such as physical or electronic addresses, 21 | without explicit permission 22 | * Other unethical or unprofessional conduct 23 | 24 | Project maintainers have the right and responsibility to remove, edit, or reject 25 | comments, commits, code, wiki edits, issues, and other contributions that are not 26 | aligned to this Code of Conduct. By adopting this Code of Conduct, project maintainers 27 | commit themselves to fairly and consistently applying these principles to every aspect 28 | of managing this project. Project maintainers who do not follow or enforce the Code of 29 | Conduct may be permanently removed from the project team. 30 | 31 | This code of conduct applies both within project spaces and in public spaces when an 32 | individual is representing the project or its community. 33 | 34 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported 35 | by opening an issue or contacting one or more of the project maintainers. 36 | 37 | This Code of Conduct is adapted from the Contributor Covenant, version 1.2.0, available 38 | from http://contributor-covenant.org/version/1/2/0/ 39 | -------------------------------------------------------------------------------- /editbox.go: -------------------------------------------------------------------------------- 1 | package jiraui 2 | 3 | import ( 4 | "unicode/utf8" 5 | ) 6 | 7 | const tabstop_length = 8 8 | 9 | type EditBox struct { 10 | text []byte 11 | line_voffset int 12 | cursor_boffset int // cursor offset in bytes 13 | cursor_voffset int // visual cursor offset in termbox cells 14 | cursor_coffset int // cursor offset in unicode code points 15 | } 16 | 17 | func byte_slice_grow(s []byte, desired_cap int) []byte { 18 | if cap(s) < desired_cap { 19 | ns := make([]byte, len(s), desired_cap) 20 | copy(ns, s) 21 | return ns 22 | } 23 | return s 24 | } 25 | 26 | func byte_slice_remove(text []byte, from, to int) []byte { 27 | size := to - from 28 | copy(text[from:], text[to:]) 29 | text = text[:len(text)-size] 30 | return text 31 | } 32 | 33 | func byte_slice_insert(text []byte, offset int, what []byte) []byte { 34 | n := len(text) + len(what) 35 | text = byte_slice_grow(text, n) 36 | text = text[:n] 37 | copy(text[offset+len(what):], text[offset:]) 38 | copy(text[offset:], what) 39 | return text 40 | } 41 | 42 | func decodeTermuiKbdStringToRune(str string) rune { 43 | r, _ := utf8.DecodeRuneInString(str) // should be a single rune 44 | return r 45 | } 46 | 47 | func (eb *EditBox) InsertRune(r rune) { 48 | var buf [utf8.UTFMax]byte 49 | n := utf8.EncodeRune(buf[:], r) 50 | eb.text = byte_slice_insert(eb.text, eb.cursor_boffset, buf[:n]) 51 | eb.MoveCursorOneRuneForward() 52 | } 53 | 54 | func (eb *EditBox) DeleteRuneBackward() { 55 | if eb.cursor_boffset == 0 { 56 | return 57 | } 58 | eb.MoveCursorOneRuneBackward() 59 | _, size := eb.RuneUnderCursor() 60 | eb.text = byte_slice_remove(eb.text, eb.cursor_boffset, eb.cursor_boffset+size) 61 | } 62 | 63 | func (eb *EditBox) MoveCursorOneRuneBackward() { 64 | if eb.cursor_boffset == 0 { 65 | return 66 | } 67 | _, size := eb.RuneBeforeCursor() 68 | eb.MoveCursorTo(eb.cursor_boffset - size) 69 | } 70 | 71 | func (eb *EditBox) MoveCursorOneRuneForward() { 72 | if eb.cursor_boffset == len(eb.text) { 73 | return 74 | } 75 | _, size := eb.RuneUnderCursor() 76 | eb.MoveCursorTo(eb.cursor_boffset + size) 77 | } 78 | 79 | func (eb *EditBox) RuneUnderCursor() (rune, int) { 80 | return utf8.DecodeRune(eb.text[eb.cursor_boffset:]) 81 | } 82 | 83 | func (eb *EditBox) RuneBeforeCursor() (rune, int) { 84 | return utf8.DecodeLastRune(eb.text[:eb.cursor_boffset]) 85 | } 86 | 87 | func (eb *EditBox) MoveCursorTo(boffset int) { 88 | eb.cursor_boffset = boffset 89 | eb.cursor_voffset, eb.cursor_coffset = voffset_coffset(eb.text, boffset) 90 | } 91 | 92 | func (eb *EditBox) MoveCursorToEnd() { 93 | eb.MoveCursorTo(len(eb.text)) 94 | } 95 | 96 | func rune_advance_len(r rune, pos int) int { 97 | if r == '\t' { 98 | return tabstop_length - pos%tabstop_length 99 | } 100 | return 1 101 | } 102 | 103 | func voffset_coffset(text []byte, boffset int) (voffset, coffset int) { 104 | text = text[:boffset] 105 | for len(text) > 0 { 106 | r, size := utf8.DecodeRune(text) 107 | text = text[size:] 108 | coffset += 1 109 | voffset += rune_advance_len(r, voffset) 110 | } 111 | return 112 | } 113 | -------------------------------------------------------------------------------- /base_list_page.go: -------------------------------------------------------------------------------- 1 | package jiraui 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | 7 | ui "gopkg.in/gizak/termui.v2" 8 | ) 9 | 10 | type Search struct { 11 | command string 12 | directionUp bool 13 | re *regexp.Regexp 14 | } 15 | 16 | type BaseListPage struct { 17 | uiList *ScrollableList 18 | cachedResults []string 19 | isPopulated bool 20 | ActiveSearch Search 21 | } 22 | 23 | func (p *BaseListPage) SetSearch(searchCommand string) { 24 | if len(searchCommand) < 2 { 25 | // must be '/a' minimum 26 | return 27 | } 28 | direction := []byte(searchCommand)[0] 29 | regex := "(?i)" + string([]byte(searchCommand)[1:]) 30 | s := new(Search) 31 | s.command = searchCommand 32 | if direction == '?' { 33 | s.directionUp = true 34 | } else if direction == '/' { 35 | s.directionUp = false 36 | } else { 37 | // bad command 38 | return 39 | } 40 | if re, err := regexp.Compile(regex); err != nil { 41 | // bad regex 42 | return 43 | } else { 44 | s.re = re 45 | p.ActiveSearch = *s 46 | } 47 | } 48 | 49 | func (p *BaseListPage) IsPopulated() bool { 50 | if len(p.cachedResults) > 0 || p.isPopulated { 51 | return true 52 | } else { 53 | return false 54 | } 55 | } 56 | 57 | func (p *BaseListPage) PreviousLine(n int) { 58 | p.uiList.CursorUpLines(n) 59 | } 60 | 61 | func (p *BaseListPage) NextLine(n int) { 62 | p.uiList.CursorDownLines(n) 63 | } 64 | 65 | func (p *BaseListPage) PreviousPara() { 66 | p.PreviousLine(5) 67 | } 68 | 69 | func (p *BaseListPage) NextPara() { 70 | p.NextLine(5) 71 | } 72 | 73 | func (p *BaseListPage) PreviousPage() { 74 | p.uiList.PageUp() 75 | } 76 | 77 | func (p *BaseListPage) NextPage() { 78 | p.uiList.PageDown() 79 | } 80 | 81 | func (p *BaseListPage) PageLines() int { 82 | return p.uiList.Height - 2 83 | } 84 | 85 | func (p *BaseListPage) TopOfPage() { 86 | p.uiList.Cursor = 0 87 | p.uiList.ScrollToTop() 88 | } 89 | 90 | func (p *BaseListPage) BottomOfPage() { 91 | p.uiList.Cursor = len(p.uiList.Items) - 1 92 | p.uiList.ScrollToBottom() 93 | } 94 | 95 | func (p *BaseListPage) Id() string { 96 | return fmt.Sprintf("BaseListPage(%p)", p) 97 | } 98 | 99 | func (p *BaseListPage) Update() { 100 | log.Debugf("BaseListPage.Update(): self: %s (%p)", p.Id(), p) 101 | log.Debugf("BaseListPage.Update(): currentPage: %s (%p)", currentPage.Id(), currentPage) 102 | ui.Render(p.uiList) 103 | } 104 | 105 | func (p *BaseListPage) Refresh() { 106 | pDeref := &p 107 | q := *pDeref 108 | q.cachedResults = make([]string, 0) 109 | changePage() 110 | q.Create() 111 | } 112 | 113 | func (p *BaseListPage) Create() { 114 | log.Debugf("BaseListPage.Create(): self: %s (%p)", p.Id(), p) 115 | log.Debugf("BaseListPage.Create(): currentPage: %s (%p)", currentPage.Id(), currentPage) 116 | ui.Clear() 117 | ls := NewScrollableList() 118 | p.uiList = ls 119 | p.cachedResults = make([]string, 0) 120 | ls.Items = p.cachedResults 121 | ls.ItemFgColor = ui.ColorYellow 122 | ls.BorderLabel = "Updating, please wait" 123 | ls.Height = ui.TermHeight() 124 | ls.Width = ui.TermWidth() 125 | ls.Y = 0 126 | p.Update() 127 | } 128 | -------------------------------------------------------------------------------- /sort_order_page.go: -------------------------------------------------------------------------------- 1 | package jiraui 2 | 3 | import ( 4 | "fmt" 5 | 6 | ui "gopkg.in/gizak/termui.v2" 7 | ) 8 | 9 | type Sort struct { 10 | Name string 11 | JQL string 12 | } 13 | 14 | type SortOrderPage struct { 15 | BaseListPage 16 | cachedResults []Sort 17 | } 18 | 19 | var baseSorts = []Sort{ 20 | Sort{"default", " "}, 21 | Sort{"created, oldest first", "ORDER BY created ASC"}, 22 | Sort{"updated, newest first", "ORDER BY updated DESC"}, 23 | Sort{"updated, oldest first", "ORDER BY updated ASC"}, 24 | Sort{"rank", "ORDER BY Rank DESC"}, 25 | Sort{"---", ""}, // no-op line in UI 26 | } 27 | 28 | func getSorts() (sorts []Sort) { 29 | opts := getJiraOpts() 30 | if q := opts["sorts"]; q != nil { 31 | qList := q.([]interface{}) 32 | for _, v := range qList { 33 | q1 := v.(map[interface{}]interface{}) 34 | q2 := make(map[string]string) 35 | for k, v := range q1 { 36 | switch k := k.(type) { 37 | case string: 38 | switch v := v.(type) { 39 | case string: 40 | q2[k] = v 41 | } 42 | } 43 | } 44 | sorts = append(sorts, Sort{q2["name"], q2["jql"]}) 45 | } 46 | } 47 | return append(baseSorts, sorts...) 48 | } 49 | 50 | func (p *SortOrderPage) IsPopulated() bool { 51 | if len(p.cachedResults) > 0 { 52 | return true 53 | } else { 54 | return false 55 | } 56 | } 57 | 58 | func (p *SortOrderPage) itemizeResults() []string { 59 | items := make([]string, len(p.cachedResults)) 60 | for i, v := range p.cachedResults { 61 | items[i] = fmt.Sprintf("%s", v.Name) 62 | } 63 | return items 64 | } 65 | 66 | func (p *SortOrderPage) PreviousPara() { 67 | newDisplayLine := 0 68 | sl := p.uiList.Cursor 69 | if sl == 0 { 70 | return 71 | } 72 | for i := sl - 1; i > 0; i-- { 73 | if p.cachedResults[i].JQL == "" { 74 | newDisplayLine = i 75 | break 76 | } 77 | } 78 | p.PreviousLine(sl - newDisplayLine) 79 | } 80 | 81 | func (p *SortOrderPage) NextPara() { 82 | newDisplayLine := len(p.cachedResults) - 1 83 | sl := p.uiList.Cursor 84 | if sl == newDisplayLine { 85 | return 86 | } 87 | for i := sl + 1; i < len(p.cachedResults); i++ { 88 | if p.cachedResults[i].JQL == "" { 89 | newDisplayLine = i 90 | break 91 | } 92 | } 93 | p.NextLine(newDisplayLine - sl) 94 | } 95 | 96 | func (p *SortOrderPage) SelectedSort() Sort { 97 | return p.cachedResults[p.uiList.Cursor] 98 | } 99 | 100 | func (p *SortOrderPage) SelectItem() { 101 | if p.SelectedSort().JQL == "" { 102 | return 103 | } 104 | q := new(TicketListPage) 105 | // pop old page, we're going to 'replace' it 106 | var oldTicketListPage Navigable 107 | if len(previousPages) > 0 { 108 | oldTicketListPage, previousPages = previousPages[len(previousPages)-1], previousPages[:len(previousPages)-1] 109 | switch a := oldTicketListPage.(type) { 110 | case *TicketListPage: 111 | q.ActiveQuery = a.ActiveQuery 112 | q.ActiveSort = p.SelectedSort() 113 | currentPage = q 114 | } 115 | } 116 | changePage() 117 | } 118 | 119 | func (p *SortOrderPage) Update() { 120 | ls := p.uiList 121 | ui.Render(ls) 122 | } 123 | 124 | func (p *SortOrderPage) Refresh() { 125 | pDeref := &p 126 | q := *pDeref 127 | q.cachedResults = make([]Sort, 0) 128 | changePage() 129 | q.Create() 130 | } 131 | 132 | func (p *SortOrderPage) Create() { 133 | ls := NewScrollableList() 134 | p.uiList = ls 135 | p.uiList.Cursor = 0 136 | if len(p.cachedResults) == 0 { 137 | p.cachedResults = getSorts() 138 | } 139 | ls.Items = p.itemizeResults() 140 | ls.ItemFgColor = ui.ColorGreen 141 | ls.BorderLabel = "Sort By..." 142 | ls.BorderFg = ui.ColorRed 143 | ls.Height = 10 144 | ls.Width = 50 145 | ls.X = ui.TermWidth()/2 - ls.Width/2 146 | ls.Y = ui.TermHeight()/2 - ls.Height/2 147 | p.Update() 148 | } 149 | -------------------------------------------------------------------------------- /label_list_page.go: -------------------------------------------------------------------------------- 1 | package jiraui 2 | 3 | import ( 4 | "fmt" 5 | 6 | ui "gopkg.in/gizak/termui.v2" 7 | ) 8 | 9 | type LabelListPage struct { 10 | BaseListPage 11 | CommandBarFragment 12 | StatusBarFragment 13 | labelCounts map[string]int 14 | ActiveQuery Query 15 | } 16 | 17 | func (p *LabelListPage) Search() { 18 | s := p.ActiveSearch 19 | n := len(p.cachedResults) 20 | if s.command == "" { 21 | return 22 | } 23 | increment := 1 24 | if s.directionUp { 25 | increment = -1 26 | } 27 | // we use modulo here so we can loop through every line. 28 | // adding 'n' means we never have '-1 % n'. 29 | startLine := (p.uiList.Cursor + n + increment) % n 30 | for i := startLine; i != p.uiList.Cursor; i = (i + increment + n) % n { 31 | if s.re.MatchString(p.cachedResults[i]) { 32 | p.uiList.SetCursorLine(i) 33 | p.Update() 34 | break 35 | } 36 | } 37 | } 38 | 39 | func (p *LabelListPage) labelsAsSortedList() []string { 40 | return sortedKeys(p.labelCounts) 41 | } 42 | 43 | func (p *LabelListPage) labelsAsSortedListWithCounts() []string { 44 | data := p.labelsAsSortedList() 45 | ret := make([]string, len(data)) 46 | for i, v := range data { 47 | ret[i] = fmt.Sprintf("%s (%d found)", v, p.labelCounts[v]) 48 | } 49 | return ret 50 | } 51 | 52 | func (p *LabelListPage) SelectItem() { 53 | label := p.cachedResults[p.uiList.Cursor] 54 | // calling TicketList page is the last 'previousPage'. We leave it on the stack, 55 | // as we will likely want to GoBack to it. 56 | switch oldTicketListPage := previousPages[len(previousPages)-1].(type) { 57 | default: 58 | return 59 | case *TicketListPage: 60 | q := new(TicketListPage) 61 | if label == "NOT LABELLED" { 62 | q.ActiveQuery.Name = oldTicketListPage.ActiveQuery.Name + " (unlabelled)" 63 | q.ActiveQuery.JQL = oldTicketListPage.ActiveQuery.JQL + " AND labels IS EMPTY" 64 | } else { 65 | q.ActiveQuery.Name = oldTicketListPage.ActiveQuery.Name + "+" + label 66 | q.ActiveQuery.JQL = oldTicketListPage.ActiveQuery.JQL + " AND labels = " + label 67 | } 68 | // our label list is useful, prob want to come back to it to look at a different 69 | // label 70 | previousPages = append(previousPages, currentPage) 71 | currentPage = q 72 | changePage() 73 | } 74 | } 75 | 76 | func (p *LabelListPage) itemizeResults() []string { 77 | items := make([]string, len(p.cachedResults)) 78 | for i, v := range p.cachedResults { 79 | items[i] = fmt.Sprintf("%-40s -- %d tickets", v, p.labelCounts[v]) 80 | } 81 | return items 82 | } 83 | 84 | func (p *LabelListPage) GoBack() { 85 | if len(previousPages) == 0 { 86 | currentPage = new(QueryPage) 87 | } else { 88 | currentPage, previousPages = previousPages[len(previousPages)-1], previousPages[:len(previousPages)-1] 89 | } 90 | changePage() 91 | } 92 | 93 | func (p *LabelListPage) Update() { 94 | ls := p.uiList 95 | ui.Render(ls) 96 | p.statusBar.Update() 97 | p.commandBar.Update() 98 | } 99 | 100 | func (p *LabelListPage) Create() { 101 | ui.Clear() 102 | if p.uiList == nil { 103 | p.uiList = NewScrollableList() 104 | } 105 | if p.statusBar == nil { 106 | p.statusBar = new(StatusBar) 107 | } 108 | if p.commandBar == nil { 109 | p.commandBar = commandBar 110 | } 111 | queryName := p.ActiveQuery.Name 112 | queryJQL := p.ActiveQuery.JQL 113 | p.labelCounts = countLabelsFromQuery(queryJQL) 114 | p.cachedResults = p.labelsAsSortedList() 115 | p.isPopulated = true 116 | p.uiList.Items = p.itemizeResults() 117 | p.uiList.ItemFgColor = ui.ColorYellow 118 | p.uiList.BorderLabel = fmt.Sprintf("Label view -- %s: %s", queryName, queryJQL) 119 | p.uiList.Height = ui.TermHeight() - 2 120 | p.uiList.Width = ui.TermWidth() 121 | p.uiList.Y = 0 122 | p.statusBar.Create() 123 | p.commandBar.Create() 124 | p.Update() 125 | } 126 | -------------------------------------------------------------------------------- /utils_test.go: -------------------------------------------------------------------------------- 1 | package jiraui 2 | 3 | import ( 4 | "encoding/json" 5 | "testing" 6 | ) 7 | 8 | func TestCountLabelsFromQueryData(t *testing.T) { 9 | var data interface{} 10 | inputJSON := []byte(`{ 11 | "issues": [ 12 | { "fields": { "labels": [ "wibble", "bibble" ] } }, 13 | { "fields": { "labels": [ "wibble", "bibble" ] } }, 14 | { "fields": { "labels": [ "bibble" ] } }, 15 | { "fields": { "labels": [] } }, 16 | { "fields": { "labels": [] } } 17 | ] 18 | }`) 19 | 20 | expected := make(map[string]int) 21 | expected["wibble"] = 2 22 | expected["bibble"] = 3 23 | expected["NOT LABELLED"] = 2 24 | 25 | err := json.Unmarshal(inputJSON, &data) 26 | if err != nil { 27 | t.Fatal(err) 28 | } 29 | 30 | actual := countLabelsFromQueryData(data) 31 | for k, v := range expected { 32 | if v != actual[k] { 33 | t.Fatalf("%s: expected %d, got %d", k, v, actual[k]) 34 | } 35 | } 36 | } 37 | 38 | func TestFindTicketIdInString(t *testing.T) { 39 | var match string 40 | match = findTicketIdInString(" relates: BLAH-123[Done] ") 41 | if match != "BLAH-123" { 42 | t.Fatalf("expected BLAH-123, got %s", match) 43 | } 44 | match = findTicketIdInString(" wibble: xxBLAH-123[Done] ") 45 | if match != "BLAH-123" { 46 | t.Fatalf("expected %q, got %q", "", match) 47 | } 48 | match = findTicketIdInString(" wibble: xxBL-1[Done] ") 49 | if match != "BL-1" { 50 | t.Fatalf("expected %q, got %q", "BL-1", match) 51 | } 52 | match = findTicketIdInString(" wibble: xxBLAH-1[Done] ") 53 | if match != "BLAH-1" { 54 | t.Fatalf("expected %q, got %q", "", match) 55 | } 56 | match = findTicketIdInString(" wibble: xxTOOLONGPROJECT-1[Done] ") 57 | if match != "" { 58 | t.Skip("This fails, TODO fixing!") 59 | } 60 | } 61 | 62 | func TestWrapText(t *testing.T) { 63 | input := []string{ 64 | "", 65 | "wibble: hello", 66 | "longfield: 1234567890123456789012345678901234567890", 67 | "1234567890123456789012345678901234567890", 68 | "12345678901234567890123456789012345678901234567890", 69 | " {code} ", 70 | " # This is code it should not be wrapped at all herpdy derp", 71 | " # weoijwefoi wpeifjwoiejf pwjefoijwefij wefjowiejf wefwefwefijwe", 72 | " {code} ", 73 | " {code:bash} ", 74 | " # This is code it should not be wrapped at all herpdy derp", 75 | " # weoijwefoi wpeifjwoiejf pwjefoijwefij wefjowiejf wefwefwefijwe", 76 | " {code} ", 77 | " {noformat} ", 78 | " # This is noformat it should not be wrapped at all herpdy derp", 79 | " # weoijwefoi wpeifjwoiejf pwjefoijwefij wefjowiejf wefwefwefijwe", 80 | " {noformat} ", 81 | "body: |", 82 | " hello there I am a line that is longer than 40 chars yes I am oh aye.", 83 | } 84 | expected := []string{ 85 | "", 86 | "wibble: hello", 87 | "longfield: 1234567890123456789012345678901234567890", 88 | "1234567890123456789012345678901234567890", 89 | "12345678901234567890123456789012345678901234567890", 90 | " {code} ", 91 | " # This is code it should not be wrapped at all herpdy derp", 92 | " # weoijwefoi wpeifjwoiejf pwjefoijwefij wefjowiejf wefwefwefijwe", 93 | " {code} ", 94 | " {code:bash} ", 95 | " # This is code it should not be wrapped at all herpdy derp", 96 | " # weoijwefoi wpeifjwoiejf pwjefoijwefij wefjowiejf wefwefwefijwe", 97 | " {code} ", 98 | " {noformat} ", 99 | " # This is noformat it should not be wrapped at all herpdy derp", 100 | " # weoijwefoi wpeifjwoiejf pwjefoijwefij wefjowiejf wefwefwefijwe", 101 | " {noformat} ", 102 | "body: |", 103 | " hello there I am a line that is", 104 | " longer than 40 chars yes I am oh aye.", 105 | } 106 | match := WrapText(input, 40) 107 | for i, _ := range expected { 108 | if i > len(match)-1 { 109 | t.Fatalf("expected %d lines, got %d", len(expected), len(match)) 110 | } else if match[i] != expected[i] { 111 | t.Fatalf("line %d - expected %q, got %q", i, expected[i], match[i]) 112 | } 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /command_bar.go: -------------------------------------------------------------------------------- 1 | package jiraui 2 | 3 | import ( 4 | ui "gopkg.in/gizak/termui.v2" 5 | ) 6 | 7 | type CommandBar struct { 8 | uiList *ui.List 9 | EditBox 10 | commandType byte 11 | commandHistoryIndex int 12 | searchHistoryIndex int 13 | commandHistory []string 14 | searchHistory []string 15 | } 16 | 17 | func (p *CommandBar) resetSearchIndex() { 18 | p.searchHistoryIndex = len(p.searchHistory) - 1 19 | } 20 | 21 | func (p *CommandBar) resetCommandIndex() { 22 | p.commandHistoryIndex = len(p.commandHistory) - 1 23 | } 24 | 25 | func addCommandIfNotSameAsLast(new string, history *[]string) { 26 | log.Debugf("addCommandIfNotSameAsLast: got %s", new) 27 | l := len(*history) 28 | if l > 0 && new == (*history)[l-1] { 29 | return 30 | } else { 31 | log.Debugf("addCommandIfNotSameAsLast: Adding %s", new) 32 | *history = append(*history, new) 33 | } 34 | } 35 | 36 | func (p *CommandBar) Submit() { 37 | if obj, ok := currentPage.(CommandBoxer); ok { 38 | obj.SetCommandMode(false) 39 | obj.ExecuteCommand() 40 | if len(p.text) > 1 { 41 | ct := p.text[0] 42 | cb := string(p.text[1:]) 43 | switch { 44 | case ct == ':': 45 | addCommandIfNotSameAsLast(cb, &p.commandHistory) 46 | p.resetCommandIndex() 47 | case (ct == '/' || ct == '?'): 48 | addCommandIfNotSameAsLast(cb, &p.searchHistory) 49 | p.resetSearchIndex() 50 | } 51 | } 52 | p.text = []byte("") 53 | } 54 | // currentPage may have changed 55 | if obj, ok := currentPage.(CommandBoxer); ok { 56 | obj.Update() 57 | } 58 | } 59 | 60 | func (p *CommandBar) PreviousCommand() { 61 | if obj, ok := currentPage.(CommandBoxer); ok { 62 | ct := p.commandType 63 | switch { 64 | case (ct == ':'): 65 | if len(p.commandHistory) == 0 { 66 | return 67 | } 68 | p.text = []byte(string(p.commandType) + p.commandHistory[p.commandHistoryIndex]) 69 | if p.commandHistoryIndex > 0 { 70 | p.commandHistoryIndex = p.commandHistoryIndex - 1 71 | } else { 72 | p.resetCommandIndex() 73 | } 74 | case (ct == '/' || ct == '?'): 75 | if len(p.searchHistory) == 0 { 76 | return 77 | } 78 | p.text = []byte(string(p.commandType) + p.searchHistory[p.searchHistoryIndex]) 79 | if p.searchHistoryIndex > 0 { 80 | p.searchHistoryIndex = p.searchHistoryIndex - 1 81 | } else { 82 | p.resetSearchIndex() 83 | } 84 | } 85 | p.MoveCursorToEnd() 86 | obj.Update() 87 | } 88 | } 89 | 90 | func (p *CommandBar) NextCommand() { 91 | if obj, ok := currentPage.(CommandBoxer); ok { 92 | ct := p.commandType 93 | switch { 94 | case (ct == ':'): 95 | if len(p.commandHistory) == 0 { 96 | return 97 | } 98 | p.text = []byte(string(p.commandType) + p.commandHistory[p.commandHistoryIndex]) 99 | if p.commandHistoryIndex < len(p.commandHistory)-1 { 100 | p.commandHistoryIndex = p.commandHistoryIndex + 1 101 | } else { 102 | p.resetCommandIndex() 103 | } 104 | case (ct == '/' || ct == '?'): 105 | if len(p.searchHistory) == 0 { 106 | return 107 | } 108 | p.text = []byte(string(p.commandType) + p.searchHistory[p.searchHistoryIndex]) 109 | if p.searchHistoryIndex < len(p.commandHistory)-1 { 110 | p.searchHistoryIndex = p.searchHistoryIndex + 1 111 | } else { 112 | p.resetSearchIndex() 113 | } 114 | } 115 | p.MoveCursorToEnd() 116 | obj.Update() 117 | } 118 | } 119 | 120 | func (p *CommandBar) Reset() { 121 | p.text = []byte(``) 122 | p.line_voffset = 0 123 | p.cursor_boffset = 0 124 | p.cursor_voffset = 0 125 | p.cursor_coffset = 0 126 | } 127 | 128 | func (p *CommandBar) Update() { 129 | if len(p.text) == 0 { 130 | if obj, ok := currentPage.(CommandBoxer); ok { 131 | obj.SetCommandMode(false) 132 | } 133 | } else { 134 | p.commandType = p.text[0] 135 | } 136 | ls := p.uiList 137 | strs := []string{string(p.text)} 138 | ls.Items = strs 139 | ui.Render(ls) 140 | } 141 | 142 | func (p *CommandBar) Create() { 143 | ls := ui.NewList() 144 | p.uiList = ls 145 | ls.ItemFgColor = ui.ColorGreen 146 | ls.Border = false 147 | ls.Height = 1 148 | ls.Width = ui.TermWidth() 149 | ls.X = 0 150 | ls.Y = ui.TermHeight() - 1 151 | p.Update() 152 | } 153 | -------------------------------------------------------------------------------- /ticket_list_page.go: -------------------------------------------------------------------------------- 1 | package jiraui 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | 7 | "gopkg.in/Netflix-Skunkworks/go-jira.v0" 8 | ui "gopkg.in/gizak/termui.v2" 9 | ) 10 | 11 | type TicketListPage struct { 12 | BaseListPage 13 | CommandBarFragment 14 | StatusBarFragment 15 | ActiveQuery Query 16 | ActiveSort Sort 17 | RankingTicketId string 18 | } 19 | 20 | func (p *TicketListPage) Search() { 21 | s := p.ActiveSearch 22 | n := len(p.cachedResults) 23 | if s.command == "" { 24 | return 25 | } 26 | increment := 1 27 | if s.directionUp { 28 | increment = -1 29 | } 30 | // we use modulo here so we can loop through every line. 31 | // adding 'n' means we never have '-1 % n'. 32 | startLine := (p.uiList.Cursor + n + increment) % n 33 | for i := startLine; i != p.uiList.Cursor; i = (i + increment + n) % n { 34 | if s.re.MatchString(p.cachedResults[i]) { 35 | p.uiList.SetCursorLine(i) 36 | p.Update() 37 | break 38 | } 39 | } 40 | } 41 | 42 | func (p *TicketListPage) ActiveTicketId() string { 43 | return p.GetSelectedTicketId() 44 | } 45 | 46 | func (p *TicketListPage) GetSelectedTicketId() string { 47 | return findTicketIdInString(p.cachedResults[p.uiList.Cursor]) 48 | } 49 | 50 | func (p *TicketListPage) MarkItemForRanking() { 51 | p.RankingTicketId = p.GetSelectedTicketId() 52 | } 53 | 54 | func (p *TicketListPage) PreviousLine(n int) { 55 | if p.RankingTicketId != "" { 56 | p.uiList.MoveUp(n) 57 | } else { 58 | p.uiList.CursorUpLines(n) 59 | } 60 | } 61 | 62 | func (p *TicketListPage) NextLine(n int) { 63 | if p.RankingTicketId != "" { 64 | p.uiList.MoveDown(n) 65 | } else { 66 | p.uiList.CursorDownLines(n) 67 | } 68 | } 69 | 70 | func (p *TicketListPage) SelectItem() { 71 | if p.RankingTicketId != "" { 72 | log.Debugf("Setting Rank for %s", p.RankingTicketId) 73 | order := jira.RANKAFTER 74 | var targetId string 75 | if p.uiList.Cursor == 0 { 76 | order = jira.RANKBEFORE 77 | targetId = findTicketIdInString(p.cachedResults[p.uiList.Cursor+1]) 78 | } else { 79 | targetId = findTicketIdInString(p.cachedResults[p.uiList.Cursor-1]) 80 | } 81 | runJiraCmdRank(p.RankingTicketId, targetId, order) 82 | p.RankingTicketId = "" 83 | p.Refresh() 84 | return 85 | } 86 | 87 | if len(p.cachedResults) == 0 { 88 | return 89 | } 90 | q := new(TicketShowPage) 91 | q.TicketId = p.GetSelectedTicketId() 92 | previousPages = append(previousPages, currentPage) 93 | currentPage = q 94 | q.Create() 95 | changePage() 96 | } 97 | 98 | func (p *TicketListPage) GoBack() { 99 | if len(previousPages) == 0 { 100 | currentPage = ticketQueryPage 101 | } else { 102 | currentPage, previousPages = previousPages[len(previousPages)-1], previousPages[:len(previousPages)-1] 103 | } 104 | changePage() 105 | } 106 | 107 | func (p *TicketListPage) EditTicket() { 108 | runJiraCmdEdit(p.GetSelectedTicketId()) 109 | } 110 | 111 | func (p *TicketListPage) Update() { 112 | ui.Render(p.uiList) 113 | p.statusBar.Update() 114 | p.commandBar.Update() 115 | } 116 | 117 | func (p *TicketListPage) Refresh() { 118 | pDeref := &p 119 | q := *pDeref 120 | q.cachedResults = make([]string, 0) 121 | currentPage = q 122 | changePage() 123 | q.Create() 124 | } 125 | 126 | func (p *TicketListPage) Create() { 127 | log.Debugf("TicketListPage.Create(): self: %s (%p)", p.Id(), p) 128 | log.Debugf("TicketListPage.Create(): currentPage: %s (%p)", currentPage.Id(), currentPage) 129 | ui.Clear() 130 | if p.uiList == nil { 131 | p.uiList = NewScrollableList() 132 | } 133 | if p.statusBar == nil { 134 | p.statusBar = new(StatusBar) 135 | } 136 | if p.commandBar == nil { 137 | p.commandBar = commandBar 138 | } 139 | query := p.ActiveQuery.JQL 140 | if sort := p.ActiveSort.JQL; sort != "" { 141 | re := regexp.MustCompile(`(?i)\s+ORDER\s+BY.+$`) 142 | query = re.ReplaceAllString(query, ``) + " " + sort 143 | } 144 | if len(p.cachedResults) == 0 { 145 | p.cachedResults = JiraQueryAsStrings(query, p.ActiveQuery.Template) 146 | } 147 | if p.uiList.Cursor >= len(p.cachedResults) { 148 | p.uiList.Cursor = len(p.cachedResults) - 1 149 | } 150 | p.uiList.Items = p.cachedResults 151 | p.uiList.ItemFgColor = ui.ColorYellow 152 | p.uiList.BorderLabel = fmt.Sprintf("%s: %s", p.ActiveQuery.Name, p.ActiveQuery.JQL) 153 | p.uiList.Height = ui.TermHeight() - 2 154 | p.uiList.Width = ui.TermWidth() 155 | p.uiList.Y = 0 156 | p.statusBar.Create() 157 | p.commandBar.Create() 158 | p.Update() 159 | } 160 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/coryb/optigo v0.0.0-20170510052407-6f3f720fe67b h1:nvEycQ6tVEDVSt7eNsBAMjBG07ChGMvR+s/rhf6tYnE= 2 | github.com/coryb/optigo v0.0.0-20170510052407-6f3f720fe67b/go.mod h1:wzGah6fEjRb2kkhAjUeVft+qTISloAdOaNSE0hvLIO4= 3 | github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 4 | github.com/guelfey/go.dbus v0.0.0-20131113121618-f6a3a2366cc3 h1:fngCxKbvZdctIsWj2hYijhAt4iK0JXSSA78B36xP0yI= 5 | github.com/guelfey/go.dbus v0.0.0-20131113121618-f6a3a2366cc3/go.mod h1:0CNX5Cvi77WEH8llpfZ/ieuqyceb1cnO5//b5zzsnF8= 6 | github.com/howeyc/gopass v0.0.0-20190910152052-7cb4b85ec19c h1:aY2hhxLhjEAbfXOx2nRJxCXezC6CO2V/yN+OCr1srtk= 7 | github.com/howeyc/gopass v0.0.0-20190910152052-7cb4b85ec19c/go.mod h1:lADxMC39cJJqL93Duh1xhAs4I2Zs8mKS89XWXFGp9cs= 8 | github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= 9 | github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= 10 | github.com/maruel/panicparse v1.5.0 h1:etK4QAf/Spw8eyowKbOHRkOfhblp/kahGUy96RvbMjI= 11 | github.com/maruel/panicparse v1.5.0/go.mod h1:aOutY/MUjdj80R0AEVI9qE2zHqig+67t2ffUDDiLzAM= 12 | github.com/mattn/go-colorable v0.1.6 h1:6Su7aK7lXmJ/U79bYtBjLNaha4Fs1Rg9plHpcH+vvnE= 13 | github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= 14 | github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY= 15 | github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= 16 | github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0= 17 | github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= 18 | github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= 19 | github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI= 20 | github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= 21 | github.com/mitchellh/go-wordwrap v1.0.0 h1:6GlHJ/LTGMrIJbwgdqdl2eEH8o+Exx/0m8ir9Gns0u4= 22 | github.com/mitchellh/go-wordwrap v1.0.0/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo= 23 | github.com/nsf/termbox-go v0.0.0-20200418040025-38ba6e5628f1 h1:lh3PyZvY+B9nFliSGTn5uFuqQQJGuNrD0MLCokv09ag= 24 | github.com/nsf/termbox-go v0.0.0-20200418040025-38ba6e5628f1/go.mod h1:IuKpRQcYE1Tfu+oAQqaLisqDeXgjyyltCfsaoYN18NQ= 25 | github.com/op/go-logging v0.0.0-20160315200505-970db520ece7 h1:lDH9UUVJtmYCjyT0CI4q8xvlXPxeZ0gYCVvWbmPlp88= 26 | github.com/op/go-logging v0.0.0-20160315200505-970db520ece7/go.mod h1:HzydrMdWErDVzsI23lYNej1Htcns9BCg93Dk0bBINWk= 27 | github.com/tmc/keyring v0.0.0-20171121202319-839169085ae1 h1:+gXfyhy0t28Guz+vFztBg45yIquB2bNtiFvbItzJtUc= 28 | github.com/tmc/keyring v0.0.0-20171121202319-839169085ae1/go.mod h1:gsa3jftQ3xia55nzIN4lXLYzDcWdxjojdKoz+N0St2Y= 29 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 30 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 h1:psW17arqaxU48Z5kZ0CQnkZWQJsqcURM6tKiBApRjXI= 31 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 32 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 33 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 34 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 35 | golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 36 | golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae h1:/WDfKMnPU+m5M4xB+6x4kaepxRw6jWvR5iDRdvjHgy8= 37 | golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 38 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 39 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 40 | gopkg.in/Netflix-Skunkworks/go-jira.v0 v0.1.15 h1:He1Li0EjE+h2XRWnN9jbW3oSJGausRGnxsACCy0jUKo= 41 | gopkg.in/Netflix-Skunkworks/go-jira.v0 v0.1.15/go.mod h1:Y5Yi0HkCjwqzGOUA3sciN5DFrSRI/XAnrYpuUul+P5A= 42 | gopkg.in/coryb/yaml.v2 v2.0.0 h1:ezxJa7ovlyKv+FVasqbCwJbpZzSBmoH2WhvfrehIrSc= 43 | gopkg.in/coryb/yaml.v2 v2.0.0/go.mod h1:Vth2iKfSejHZ3p6akgWO0iSjuuiu6mNCEgzcYUCnumw= 44 | gopkg.in/gizak/termui.v2 v2.3.0 h1:aAscjYf4fcnFC+mz4KBOrxY9//GHizFcRtypHo/1TFo= 45 | gopkg.in/gizak/termui.v2 v2.3.0/go.mod h1:S1qliobNx/hMi1pcikF4xnX8U0J2HY1uzAUp/CP6vUE= 46 | gopkg.in/op/go-logging.v1 v1.0.0-20160211212156-b2cb9fa56473 h1:6D+BvnJ/j6e222UW8s2qTSe3wGBtvo0MbVQG/c5k8RE= 47 | gopkg.in/op/go-logging.v1 v1.0.0-20160211212156-b2cb9fa56473/go.mod h1:N1eN2tsCx0Ydtgjl4cqmbRCsY4/+z4cYDeqwZTk6zog= 48 | -------------------------------------------------------------------------------- /query_list_page.go: -------------------------------------------------------------------------------- 1 | package jiraui 2 | 3 | import ( 4 | "fmt" 5 | 6 | ui "gopkg.in/gizak/termui.v2" 7 | ) 8 | 9 | type Query struct { 10 | Name string 11 | JQL string 12 | Template string 13 | } 14 | 15 | type QueryPage struct { 16 | BaseListPage 17 | CommandBarFragment 18 | StatusBarFragment 19 | cachedResults []Query 20 | } 21 | 22 | var baseQueries = []Query{ 23 | Query{"My Assigned Tickets", "assignee = currentUser() AND resolution = Unresolved", ""}, 24 | Query{"My Reported Tickets", "reporter = currentUser() AND resolution = Unresolved", ""}, 25 | Query{"My Watched Tickets", "watcher = currentUser() AND resolution = Unresolved", ""}, 26 | Query{"My Voted Tickets", "voter = currentUser() AND resolution = Unresolved", ""}, 27 | } 28 | 29 | func getQueries() (queries []Query) { 30 | opts := getJiraOpts() 31 | if q := opts["queries"]; q != nil { 32 | qList := q.([]interface{}) 33 | for _, v := range qList { 34 | q1 := v.(map[interface{}]interface{}) 35 | q2 := make(map[string]string) 36 | for k, v := range q1 { 37 | switch k := k.(type) { 38 | case string: 39 | switch v := v.(type) { 40 | case string: 41 | q2[k] = v 42 | } 43 | } 44 | } 45 | queries = append(queries, Query{q2["name"], q2["jql"], q2["template"]}) 46 | } 47 | } 48 | if len(queries) > 0 { 49 | queries = append( 50 | queries, 51 | Query{"---", "", ""}, // no-op line in UI 52 | ) 53 | return append( 54 | queries, 55 | baseQueries..., 56 | ) 57 | } 58 | return baseQueries 59 | } 60 | 61 | func (p *QueryPage) Search() { 62 | s := p.ActiveSearch 63 | log.Debugf("QueryPage: search! %q", s.command) 64 | n := len(p.cachedResults) 65 | if s.command == "" { 66 | return 67 | } 68 | increment := 1 69 | if s.directionUp { 70 | increment = -1 71 | } 72 | // we use modulo here so we can loop through every line. 73 | // adding 'n' means we never have '-1 % n'. 74 | startLine := (p.uiList.Cursor + n + increment) % n 75 | for i := startLine; i != p.uiList.Cursor; i = (i + increment + n) % n { 76 | if s.re.MatchString(p.cachedResults[i].Name) { 77 | log.Debugf("Match found, line %d", i) 78 | p.uiList.SetCursorLine(i) 79 | p.Update() 80 | break 81 | } 82 | } 83 | } 84 | 85 | func (p *QueryPage) IsPopulated() bool { 86 | if len(p.cachedResults) > 0 { 87 | return true 88 | } else { 89 | return false 90 | } 91 | } 92 | 93 | func (p *QueryPage) itemizeResults() []string { 94 | items := make([]string, len(p.cachedResults)) 95 | for i, v := range p.cachedResults { 96 | items[i] = fmt.Sprintf("%-50s [|](fg-blue) [%s](fg-green)", v.Name, v.JQL) 97 | } 98 | return items 99 | } 100 | 101 | func (p *QueryPage) PreviousPara() { 102 | newDisplayLine := 0 103 | sl := p.uiList.Cursor 104 | if sl == 0 { 105 | return 106 | } 107 | for i := sl - 1; i > 0; i-- { 108 | if p.cachedResults[i].JQL == "" { 109 | newDisplayLine = i 110 | break 111 | } 112 | } 113 | p.PreviousLine(sl - newDisplayLine) 114 | } 115 | 116 | func (p *QueryPage) NextPara() { 117 | newDisplayLine := len(p.cachedResults) - 1 118 | sl := p.uiList.Cursor 119 | if sl == newDisplayLine { 120 | return 121 | } 122 | for i := sl + 1; i < len(p.cachedResults); i++ { 123 | if p.cachedResults[i].JQL == "" { 124 | newDisplayLine = i 125 | break 126 | } 127 | } 128 | p.NextLine(newDisplayLine - sl) 129 | } 130 | 131 | func (p *QueryPage) SelectedQuery() Query { 132 | return p.cachedResults[p.uiList.Cursor] 133 | } 134 | 135 | func (p *QueryPage) SelectItem() { 136 | if p.SelectedQuery().JQL == "" { 137 | return 138 | } 139 | q := new(TicketListPage) 140 | q.ActiveQuery = p.SelectedQuery() 141 | previousPages = append(previousPages, currentPage) 142 | currentPage = q 143 | changePage() 144 | } 145 | 146 | func (p *QueryPage) Update() { 147 | ls := p.uiList 148 | log.Debugf("QueryPage.Update(): self: %s (%p), ls: (%p)", p.Id(), p, ls) 149 | ui.Render(ls) 150 | p.statusBar.Update() 151 | p.commandBar.Update() 152 | } 153 | 154 | func (p *QueryPage) Refresh() { 155 | pDeref := &p 156 | q := *pDeref 157 | q.cachedResults = make([]Query, 0) 158 | changePage() 159 | q.Create() 160 | } 161 | 162 | func (p *QueryPage) Create() { 163 | log.Debugf("QueryPage.Create(): self: %s (%p)", p.Id(), p) 164 | log.Debugf("QueryPage.Create(): currentPage: %s (%p)", currentPage.Id(), currentPage) 165 | ui.Clear() 166 | if p.uiList == nil { 167 | p.uiList = NewScrollableList() 168 | } 169 | if p.statusBar == nil { 170 | p.statusBar = new(StatusBar) 171 | } 172 | if p.commandBar == nil { 173 | p.commandBar = commandBar 174 | } 175 | if p.statusBar == nil { 176 | p.statusBar = new(StatusBar) 177 | } 178 | if p.commandBar == nil { 179 | p.commandBar = commandBar 180 | } 181 | p.cachedResults = getQueries() 182 | p.uiList.Items = p.itemizeResults() 183 | p.uiList.ItemFgColor = ui.ColorYellow 184 | p.uiList.BorderLabel = "Queries" 185 | p.uiList.Height = ui.TermHeight() - 2 186 | p.uiList.Width = ui.TermWidth() 187 | p.uiList.Y = 0 188 | p.statusBar.Create() 189 | p.commandBar.Create() 190 | p.Update() 191 | } 192 | -------------------------------------------------------------------------------- /templates.go: -------------------------------------------------------------------------------- 1 | package jiraui 2 | 3 | const ( 4 | default_list_template = `{{ range .issues }}[{{ .key | printf "%-18s"}}](fg-red) [{{ if .fields.assignee }}{{ .fields.assignee.name | printf "%-10s" }}{{else}}{{"Unassigned"| printf "%-10s" }}{{end}} ](fg-blue) [{{ .fields.status.name | printf "%-12s"}}](fg-blue) [{{ dateFormat "2006-01-02" .fields.created }}](fg-blue)/[{{ age .fields.updated | printf "%-15s" }}](fg-green) {{ .fields.summary | printf "%-75s"}} 5 | {{ end }}` 6 | default_view_template = ` 7 | issue: [{{ .key }}](fg-red) 8 | summary: [{{ .fields.summary }}](fg-blue) 9 | 10 | browse: ENDPOINT/browse/{{ .key }} 11 | self: {{ .self }} 12 | priority: {{ .fields.priority.name }} 13 | status: {{ .fields.status.name }} 14 | votes: {{ .fields.votes.votes }} 15 | created: {{ .fields.created }} ({{ age .fields.created }} ago) 16 | updated: {{ .fields.updated }} ({{ age .fields.updated }} ago) 17 | assignee: {{ if .fields.assignee }}{{ .fields.assignee.name }}{{end}} 18 | reporter: {{ if .fields.reporter }}{{ .fields.reporter.name }}{{end}} 19 | issuetype: {{ .fields.issuetype.name }} 20 | {{if eq .fields.issuetype.name "Epic" }}epic_links: [](fg-red){{end}} 21 | {{if .fields.customfield_10001 }}epic: [{{ .fields.customfield_10001 }}](fg-red){{end}} 22 | {{if .fields.parent }}parent: [{{ .fields.parent.key }}](fg-red) -- {{ .fields.parent.fields.summary }}{{end}} 23 | subtasks: 24 | {{ range .fields.subtasks }} - [{{ .key }}](fg-red)[{{.fields.status.name}}] -- {{.fields.summary}} 25 | {{end}} 26 | 27 | [labels:](fg-green){{ range .fields.labels }} {{ . }}{{end}} 28 | [components:](fg-green){{ range .fields.components }} {{ .name }}{{end}} 29 | [watchers:](fg-green){{ range .fields.customfield_10304 }} {{ .name }}{{end}} 30 | [blockers:](fg-green) 31 | {{ range .fields.issuelinks }}{{if .outwardIssue}} - [{{ .outwardIssue.key }}](fg-red)[{{.outwardIssue.fields.status.name}}] -- {{.outwardIssue.fields.summary}} 32 | {{end}}{{end}} 33 | [depends:](fg-green) 34 | {{ range .fields.issuelinks }}{{if .inwardIssue}} - [{{ .inwardIssue.key }}](fg-red)[{{.inwardIssue.fields.status.name}}] -- {{.inwardIssue.fields.summary}} 35 | {{end}}{{end}} 36 | 37 | [description:](fg-green) 38 | 39 | {{ or .fields.description "" | indent 2 }} 40 | 41 | [comments:](fg-green) 42 | 43 | {{ range .fields.comment.comments }} 44 | - [{{.author.name}} at {{.created}}](fg-blue) 45 | {{ or .body "" | indent 4}} 46 | 47 | {{end}} 48 | ` 49 | default_help_template = ` 50 | [Quick reference for jira-ui](fg-white) 51 | 52 | [Actions:](fg-blue) 53 | 54 | - select query/ticket 55 | L - Label view (query results page only) 56 | E - Edit ticket 57 | S - Select sort order (query results page only) 58 | v - Vote for the selected ticket 59 | V - Remove vote on the selected ticket 60 | w - Watch the selected ticket 61 | W - Unwatch the selected ticket 62 | N - Show next ticket in query results (ticket detail page only) 63 | P - Show prev ticket in query results (ticket detail page only) 64 | h - show help page 65 | 66 | [Commands (a'la vim/tig):](fg-blue) 67 | 68 | :comment {single-line-comment} - add a short comment to ticket 69 | :label {labels} - add labels to selected ticket 70 | :label add/remove {labels} - add/remove labels to selected ticket 71 | :take - assign ticket to self 72 | :assign {user} - assign ticket to {user} 73 | :unassign - unassign ticket 74 | :vote - vote for the selected ticket 75 | :unvote - remove vote for the selected ticket 76 | :watch [add/remove] [watcher] - watch ticket (optionally as a different user) 77 | :view {ticket} - display {ticket} 78 | :query {JQL} - display results of JQL 79 | :search|so {text} - quick search for {text} in open tickets 80 | :search-all|sa {text} - quick search for {text} in all tickets 81 | :spo {project} {text} - quick search for {text} in open {project} tickets 82 | :spa {project} {text} - quick search for {text} in all {project} tickets 83 | :help - show help page 84 | : - select previous command 85 | :quit or :q - quit 86 | 87 | [Navigation:](fg-blue) 88 | 89 | up/k - previous line 90 | down/j - next line 91 | C-f/ - next page 92 | C-b - previous page 93 | } - next paragraph/section/fast-move 94 | { - previous paragraph/section/fast-move 95 | n - next search match 96 | g - go to top of page 97 | G - go to bottom of page 98 | q - go back / quit 99 | C-c/Q - quit 100 | 101 | [Configuration:](fg-blue) 102 | 103 | It is very much recommended to read the go-jira documentation, 104 | particularly surrounding the .jira.d configuration directories. 105 | 106 | go-jira-ui uses this same mechanism, so can be used to load per-project 107 | defaults. It also leverages the templating engine, so you can customise 108 | the view of both the query output (use 'jira_ui_list' template), and the 109 | issue 'view' template. 110 | 111 | go-jira-ui reads its own [jira-ui-config.yml](fg-green) file in these 112 | jira.d directories, as not to pollute the go-jira config. You can add 113 | additional queries & sort orderings to the top-level Query page: 114 | 115 | $ cat ~/jira.d/jira-ui-config.yml: 116 | sorts: 117 | - name: "sort by vote count" 118 | jql: "ORDER BY votes DESC" 119 | queries: 120 | - name: "alice assigned" 121 | jql: "assignee = alice AND resolution = Unresolved" 122 | - name: "bob assigned" 123 | jql: "assignee = bob AND resolution = Unresolved" 124 | - name: "unresolved must-do" 125 | jql: "labels = 'must-do' AND resolution = Unresolved AND ( project = 'OPS' OR project = 'INFRA')" 126 | 127 | Learning JQL is highly recommended, the Atlassian Advanced Searching 128 | page is a good place to start. 129 | 130 | ` 131 | ) 132 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | go-jira-ui 2 | ---------- 3 | 4 | go-jira-ui is an ncurses command line tool for accessing JIRA. 5 | 6 | ![Screenshot showing issue list from ad-hoc query](screenshot.png) 7 | 8 | It is built around the excellent [go-jira](https://github.com/Netflix-Skunkworks/go-jira) and 9 | [termui](https://github.com/gizak/termui) libraries. 10 | 11 | It aims to be similar to familiar tools like vim, tig, and less. 12 | 13 | In order to use this, you should configure an 'endpoint' as per the go-jira 14 | documentation: 15 | 16 | $ cat ~/.jira.d/config.yml 17 | --- 18 | endpoint: https://jira.example.com/ 19 | user: bob # if not same as $USER 20 | 21 | This should be all that's needed to get going. 22 | 23 | ### Installation 24 | 25 | # Make sure you have GOPATH and GOBIN set appropriately first: 26 | # eg: 27 | # export GOPATH=$HOME/go 28 | # export GOBIN=$GOPATH/bin 29 | # mkdir -p $GOPATH 30 | # export PATH=$PATH:$GOBIN 31 | go get -v github.com/mikepea/go-jira-ui/jira-ui 32 | 33 | ### Dockerised use 34 | 35 | ```sh 36 | docker run --rm -it -v /path/to/.jira.d:/config pmjohann/go-jira-ui 37 | ``` 38 | 39 | ### Features 40 | 41 | * Supply your own JQL queries to view 42 | * Label view of a given query, to see categorisations easily 43 | * Sorting of queries; supply your own custom sorts 44 | * View tickets from the query 45 | * Drill into sub/blocker/related/mentioned tickets in details view 46 | * Show open tickets in an Epic. 47 | * Basic compatibility with [go-jira](https://github.com/Netflix-Skunkworks/go-jira) commandline and options loading 48 | * Label adding/removing 49 | * Comment, watch, assign and take implemented via :-mode commands 50 | 51 | At present, edit will exit after the update. This is a workaround 52 | to an implementation issue, being tracked in [#8](https://github.com/mikepea/go-jira-ui/issues/8) 53 | 54 | ### Usage 55 | 56 | `jira-ui` is intended to mirror the options of go-jira's `jira` tool, where 57 | useful: 58 | 59 | jira-ui # opens up in Query List page. Default interface. 60 | jira-ui ISSUE # opens up Ticket Show page, with ISSUE loaded 61 | jira-ui ls -q JQL # opens up Ticket List page, with results of JQL loaded. 62 | jira-ui -h # help page 63 | 64 | ### Basic keys 65 | 66 | Actions: 67 | 68 | - select query/ticket 69 | r - mark ticket for ranking (use naviation to change rank, to submit) 70 | L - Label view (query results page only) 71 | E - Edit ticket 72 | S - Select sort order (query results page only) 73 | w - Watch the selected ticket 74 | W - Unwatch the selected ticket 75 | v - Vote for the selected ticket 76 | V - Remove vote on the selected ticket 77 | N - Next ticket in results 78 | P - Previous ticket in results 79 | h - show help page 80 | 81 | Commands (like vim/tig/less): 82 | 83 | :comment {single-line-comment} - add a short comment to ticket 84 | :label {labels} - add labels to selected ticket 85 | :label add/remove {labels} - add/remove labels to selected ticket 86 | :take - assign ticket to self 87 | :assign {user} - assign ticket to {user} 88 | :unassign - unassign ticket 89 | :watch [add/remove] [watcher] - watch ticket (optionally as a different user) 90 | :vote - vote for the selected ticket 91 | :unvote - remove vote for the selected ticket 92 | :view {ticket} - display {ticket} 93 | :query {JQL} - display results of JQL 94 | :search|so {text} - quick search for {text} in open tickets 95 | :search-all|sa {text} - quick search for {text} in all tickets 96 | :spo {project} {text} - quick search for {text} in open {project} tickets 97 | :spa {project} {text} - quick search for {text} in all {project} tickets 98 | :help - show help page 99 | : - select previous command 100 | :quit or :q - quit 101 | 102 | Searching: 103 | 104 | /{regex} - search down 105 | ?{regex} - search up 106 | 107 | Navigation: 108 | 109 | up/k - previous line 110 | down/j - next line 111 | C-f/ - next page 112 | C-b - previous page 113 | } - next paragraph/section/fast-move 114 | { - previous paragraph/section/fast-move 115 | n - next search match 116 | g - go to top of page 117 | G - go to bottom of page 118 | q - go back / quit 119 | C-c/Q - quit 120 | 121 | 122 | ### Configuration 123 | 124 | It is very much recommended to read the 125 | [go-jira](https://github.com/Netflix-Skunkworks/go-jira) documentation, 126 | particularly surrounding the .jira.d configuration directories. go-jira-ui uses 127 | this same mechanism, so can be used to load per-project defaults. It also 128 | leverages the templating engine, so you can customise the view of both the 129 | query output (use 'jira_ui_list' template), and the issue 'view' template. 130 | 131 | go-jira-ui reads its own `jira-ui-config.yml` file in these jira.d 132 | directories, as not to pollute the go-jira config. You can add additional 133 | queries & sort orderings to the top-level Query page: 134 | 135 | $ cat ~/jira.d/jira-ui-config.yml: 136 | sorts: 137 | - name: "sort by vote count" 138 | jql: "ORDER BY votes DESC" 139 | queries: 140 | - name: "alice assigned" 141 | jql: "assignee = alice AND resolution = Unresolved" 142 | - name: "bob assigned" 143 | jql: "assignee = bob AND resolution = Unresolved" 144 | - name: "unresolved must-do" 145 | jql: "labels = 'must-do' AND resolution = Unresolved AND ( project = 'OPS' OR project = 'INFRA')" 146 | 147 | Learning JQL is highly recommended, the Atlassian [Advanced 148 | Searching](https://confluence.atlassian.com/jira/advanced-searching-179442050.html) 149 | page is a good place to start. 150 | -------------------------------------------------------------------------------- /scrollablelist.go: -------------------------------------------------------------------------------- 1 | package jiraui 2 | 3 | import ( 4 | ui "gopkg.in/gizak/termui.v2" 5 | ) 6 | 7 | // A scrollable list with a cursor. To "deactivate" the cursor, just make the 8 | // cursor colors the same as the item colors. 9 | type ScrollableList struct { 10 | ui.Block 11 | 12 | // The items in the list 13 | Items []string 14 | 15 | // The window's offset relative to the start of `Items` 16 | Offset int 17 | 18 | // The foreground color for non-cursor items 19 | ItemFgColor ui.Attribute 20 | 21 | // The background color for non-cursor items 22 | ItemBgColor ui.Attribute 23 | 24 | // The foreground color for the cursor 25 | CursorFgColor ui.Attribute 26 | 27 | // The background color for the cursor 28 | CursorBgColor ui.Attribute 29 | 30 | // The position of the cursor relative to the start of `Items` 31 | Cursor int 32 | } 33 | 34 | // NewScrollableList returns a new *ScrollableList with current theme. 35 | func NewScrollableList() *ScrollableList { 36 | l := &ScrollableList{Block: *ui.NewBlock()} 37 | l.CursorBgColor = ui.ColorBlue 38 | l.CursorFgColor = ui.ColorWhite 39 | return l 40 | } 41 | 42 | // Add an element to the list 43 | func (sl *ScrollableList) Add(s string) { 44 | sl.Items = append(sl.Items, s) 45 | sl.render() 46 | } 47 | 48 | func (sl *ScrollableList) render() { 49 | ui.Render(sl) 50 | } 51 | 52 | func (sl *ScrollableList) colorsForItem(i int) (fg, bg ui.Attribute) { 53 | if i == sl.Cursor { 54 | return sl.CursorFgColor, sl.CursorBgColor 55 | } 56 | return sl.ItemFgColor, sl.ItemBgColor 57 | } 58 | 59 | func min(a, b int) int { 60 | if a < b { 61 | return a 62 | } 63 | return b 64 | } 65 | 66 | func max(a, b int) int { 67 | if a > b { 68 | return a 69 | } 70 | return b 71 | } 72 | 73 | // Implements the termui.Bufferer interface 74 | func (sl *ScrollableList) Buffer() ui.Buffer { 75 | buf := sl.Block.Buffer() 76 | start := min(sl.Offset, len(sl.Items)) 77 | end := min(sl.Offset+sl.InnerHeight(), len(sl.Items)) 78 | for i, item := range sl.Items[start:end] { 79 | fg, bg := sl.colorsForItem(start + i) 80 | if item == "" { 81 | item = " " 82 | } 83 | cells := ui.DefaultTxBuilder.Build(item, fg, bg) 84 | cells = ui.DTrimTxCls(cells, sl.InnerWidth()) 85 | offsetX := 0 86 | for _, cell := range cells { 87 | width := cell.Width() 88 | buf.Set( 89 | sl.InnerBounds().Min.X+offsetX, 90 | sl.InnerBounds().Min.Y+i, 91 | cell, 92 | ) 93 | offsetX += width 94 | } 95 | } 96 | return buf 97 | } 98 | 99 | // Move the window up one row 100 | func (sl *ScrollableList) ScrollUp() { 101 | if sl.Offset > 0 { 102 | sl.Offset -= 1 103 | if sl.Cursor >= sl.Offset+sl.InnerHeight() { 104 | sl.Cursor = sl.Offset + sl.InnerHeight() - 1 105 | } 106 | sl.render() 107 | } 108 | } 109 | 110 | // Move the window down one row 111 | func (sl *ScrollableList) ScrollDown() { 112 | if sl.Offset < len(sl.Items) { 113 | sl.Offset += 1 114 | if sl.Offset > sl.Cursor { 115 | sl.Cursor = sl.Offset 116 | } 117 | sl.render() 118 | } 119 | } 120 | 121 | // Swap current row with previous row, then move 122 | // cursor to previous row 123 | func (sl *ScrollableList) MoveUp(n int) { 124 | if sl.Cursor >= n { 125 | cur := sl.Items[sl.Cursor] 126 | up := sl.Items[sl.Cursor-n] 127 | sl.Items[sl.Cursor] = up 128 | sl.Items[sl.Cursor-n] = cur 129 | } 130 | sl.CursorUpLines(n) 131 | } 132 | 133 | // Swap current row with next row, then move 134 | // cursor to next row 135 | func (sl *ScrollableList) MoveDown(n int) { 136 | if sl.Cursor < len(sl.Items)-n { 137 | cur := sl.Items[sl.Cursor] 138 | down := sl.Items[sl.Cursor+n] 139 | sl.Items[sl.Cursor] = down 140 | sl.Items[sl.Cursor+n] = cur 141 | } 142 | sl.CursorDownLines(n) 143 | } 144 | 145 | // Move the cursor down one row; moving the cursor out of the window will cause 146 | // scrolling. 147 | func (sl *ScrollableList) CursorDown() { 148 | sl.CursorDownLines(1) 149 | } 150 | 151 | func (sl *ScrollableList) CursorDownLines(n int) { 152 | sl.SilentCursorDownLines(n) 153 | sl.render() 154 | } 155 | 156 | func (sl *ScrollableList) SilentCursorDownLines(n int) { 157 | if sl.Cursor < len(sl.Items)-n { 158 | sl.Cursor += n 159 | } else { 160 | sl.Cursor = len(sl.Items) - 1 161 | } 162 | if sl.Cursor > sl.Offset+sl.InnerHeight()-n { 163 | sl.Offset += n 164 | } 165 | } 166 | 167 | // Move the cursor up one row; moving the cursor out of the window will cause 168 | // scrolling. 169 | func (sl *ScrollableList) CursorUp() { 170 | sl.CursorUpLines(1) 171 | } 172 | 173 | func (sl *ScrollableList) CursorUpLines(n int) { 174 | sl.SilentCursorUpLines(n) 175 | sl.render() 176 | } 177 | 178 | func (sl *ScrollableList) SilentCursorUpLines(n int) { 179 | if sl.Cursor > n { 180 | sl.Cursor -= n 181 | } else { 182 | sl.Cursor = 0 183 | } 184 | if sl.Cursor < sl.Offset { 185 | sl.Offset = sl.Cursor 186 | } 187 | } 188 | 189 | func (sl *ScrollableList) SetCursorLine(n int) { 190 | if n > len(sl.Items) || n < 0 { 191 | return 192 | } 193 | if !(n >= sl.Offset && n < min(sl.Offset+sl.InnerHeight(), len(sl.Items))) { 194 | // not on same page 195 | if n < sl.Cursor { 196 | // scrolling up to new line 197 | if sl.Offset > n { 198 | sl.Offset = n 199 | } 200 | } else { 201 | // scrolling down to new line 202 | if sl.Offset < n { 203 | sl.Offset = n 204 | } 205 | } 206 | } 207 | sl.Cursor = n 208 | sl.render() 209 | } 210 | 211 | // Move the window down one frame; this will move the cursor as well. 212 | func (sl *ScrollableList) PageDown() { 213 | if sl.Offset < len(sl.Items)-sl.InnerHeight() { 214 | sl.Offset += sl.InnerHeight() 215 | if sl.Offset > sl.Cursor { 216 | sl.Cursor = sl.Offset 217 | } 218 | sl.render() 219 | } 220 | } 221 | 222 | // Move the window up one frame; this will move the cursor as well. 223 | func (sl *ScrollableList) PageUp() { 224 | sl.Offset = max(0, sl.Offset-sl.InnerHeight()) 225 | if sl.Cursor >= sl.Offset+sl.InnerHeight() { 226 | sl.Cursor = sl.Offset + sl.InnerHeight() - 1 227 | } 228 | sl.render() 229 | } 230 | 231 | // Scroll to the bottom of the list 232 | func (sl *ScrollableList) ScrollToBottom() { 233 | if len(sl.Items) >= sl.InnerHeight() { 234 | sl.Offset = len(sl.Items) - sl.InnerHeight() 235 | sl.render() 236 | } 237 | } 238 | 239 | // Scroll to the top of the list 240 | func (sl *ScrollableList) ScrollToTop() { 241 | sl.Offset = 0 242 | sl.render() 243 | } 244 | -------------------------------------------------------------------------------- /ticket_show_page.go: -------------------------------------------------------------------------------- 1 | package jiraui 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | 7 | ui "gopkg.in/gizak/termui.v2" 8 | ) 9 | 10 | const ( 11 | defaultMaxWrapWidth = 100 12 | ) 13 | 14 | type TicketShowPage struct { 15 | BaseListPage 16 | CommandBarFragment 17 | StatusBarFragment 18 | MaxWrapWidth uint 19 | TicketId string 20 | Template string 21 | apiBody interface{} 22 | TicketTrail []*TicketShowPage // previously viewed tickets in drill-down 23 | WrapWidth uint 24 | opts map[string]interface{} 25 | } 26 | 27 | func (p *TicketShowPage) Search() { 28 | s := p.ActiveSearch 29 | n := len(p.cachedResults) 30 | if s.command == "" { 31 | return 32 | } 33 | increment := 1 34 | if s.directionUp { 35 | increment = -1 36 | } 37 | // we use modulo here so we can loop through every line. 38 | // adding 'n' means we never have '-1 % n'. 39 | startLine := (p.uiList.Cursor + n + increment) % n 40 | for i := startLine; i != p.uiList.Cursor; i = (i + increment + n) % n { 41 | if s.re.MatchString(p.cachedResults[i]) { 42 | p.uiList.SetCursorLine(i) 43 | p.Update() 44 | break 45 | } 46 | } 47 | } 48 | 49 | func (p *TicketShowPage) SelectItem() { 50 | selected := p.cachedResults[p.uiList.Cursor] 51 | if ok, _ := regexp.MatchString(`^epic_links:`, selected); ok { 52 | q := new(TicketListPage) 53 | q.ActiveQuery.Name = fmt.Sprintf("Open Tasks in Epic %s", p.TicketId) 54 | q.ActiveQuery.JQL = fmt.Sprintf("\"Epic Link\" = %s AND resolution = Unresolved", p.TicketId) 55 | previousPages = append(previousPages, currentPage) 56 | currentPage = q 57 | } else { 58 | newTicketId := findTicketIdInString(selected) 59 | if newTicketId == "" { 60 | return 61 | } else if newTicketId == p.TicketId { 62 | return 63 | } 64 | q := new(TicketShowPage) 65 | q.TicketId = newTicketId 66 | q.TicketTrail = append(p.TicketTrail, p) 67 | currentPage = q 68 | } 69 | changePage() 70 | } 71 | 72 | func (p *TicketShowPage) Id() string { 73 | return p.TicketId 74 | } 75 | 76 | func (p *TicketShowPage) NextTicket() { 77 | if len(previousPages) == 0 { 78 | return 79 | } 80 | pp := previousPages[len(previousPages)-1] 81 | switch pp := pp.(type) { 82 | case *TicketListPage: 83 | line := pp.uiList.Cursor 84 | pp.uiList.SilentCursorDownLines(1) 85 | if pp.uiList.Cursor == line { 86 | return 87 | } 88 | q := new(TicketShowPage) 89 | q.TicketId = pp.ActiveTicketId() 90 | currentPage = q 91 | changePage() 92 | } 93 | return 94 | } 95 | 96 | func (p *TicketShowPage) PrevTicket() { 97 | if len(previousPages) == 0 { 98 | return 99 | } 100 | pp := previousPages[len(previousPages)-1] 101 | switch pp := pp.(type) { 102 | case *TicketListPage: 103 | line := pp.uiList.Cursor 104 | pp.uiList.SilentCursorUpLines(1) 105 | if pp.uiList.Cursor == line { 106 | return 107 | } 108 | q := new(TicketShowPage) 109 | q.TicketId = pp.ActiveTicketId() 110 | currentPage = q 111 | changePage() 112 | } 113 | return 114 | } 115 | 116 | func (p *TicketShowPage) PreviousPara() { 117 | newDisplayLine := 0 118 | sl := p.uiList.Cursor 119 | if sl == 0 { 120 | return 121 | } 122 | for i := sl - 1; i > 0; i-- { 123 | if ok, _ := regexp.MatchString(`^\s*$`, p.cachedResults[i]); ok { 124 | newDisplayLine = i 125 | break 126 | } 127 | } 128 | p.PreviousLine(sl - newDisplayLine) 129 | } 130 | 131 | func (p *TicketShowPage) NextPara() { 132 | newDisplayLine := len(p.cachedResults) - 1 133 | sl := p.uiList.Cursor 134 | if sl == newDisplayLine { 135 | return 136 | } 137 | for i := sl + 1; i < len(p.cachedResults); i++ { 138 | if ok, _ := regexp.MatchString(`^\s*$`, p.cachedResults[i]); ok { 139 | newDisplayLine = i 140 | break 141 | } 142 | } 143 | p.NextLine(newDisplayLine - sl) 144 | } 145 | 146 | func (p *TicketShowPage) GoBack() { 147 | if len(p.TicketTrail) == 0 { 148 | if len(previousPages) > 0 { 149 | currentPage, previousPages = previousPages[len(previousPages)-1], previousPages[:len(previousPages)-1] 150 | } else { 151 | currentPage = new(QueryPage) 152 | } 153 | } else { 154 | last := len(p.TicketTrail) - 1 155 | currentPage = p.TicketTrail[last] 156 | } 157 | changePage() 158 | } 159 | 160 | func (p *TicketShowPage) EditTicket() { 161 | runJiraCmdEdit(p.TicketId) 162 | } 163 | 164 | func (p *TicketShowPage) ActiveTicketId() string { 165 | return p.TicketId 166 | } 167 | 168 | func (p *TicketShowPage) ticketTrailAsString() (trail string) { 169 | for i := len(p.TicketTrail) - 1; i >= 0; i-- { 170 | q := *p.TicketTrail[i] 171 | trail = trail + " <- " + q.Id() 172 | } 173 | return trail 174 | } 175 | 176 | func (p *TicketShowPage) Refresh() { 177 | pDeref := &p 178 | q := *pDeref 179 | q.cachedResults = make([]string, 0) 180 | q.apiBody = nil 181 | currentPage = q 182 | changePage() 183 | q.Create() 184 | } 185 | 186 | func (p *TicketShowPage) Update() { 187 | ui.Render(p.uiList) 188 | p.statusBar.Update() 189 | p.commandBar.Update() 190 | } 191 | 192 | func (p *TicketShowPage) Create() { 193 | log.Debugf("TicketShowPage.Create(): self: %s (%p)", p.Id(), p) 194 | log.Debugf("TicketShowPage.Create(): currentPage: %s (%p)", currentPage.Id(), currentPage) 195 | p.opts = getJiraOpts() 196 | if p.TicketId == "" { 197 | return 198 | } 199 | if p.MaxWrapWidth == 0 { 200 | if m := p.opts["max_wrap"]; m != nil { 201 | p.MaxWrapWidth = uint(m.(int64)) 202 | } else { 203 | p.MaxWrapWidth = defaultMaxWrapWidth 204 | } 205 | } 206 | ui.Clear() 207 | ls := NewScrollableList() 208 | if p.statusBar == nil { 209 | p.statusBar = new(StatusBar) 210 | } 211 | if p.commandBar == nil { 212 | p.commandBar = commandBar 213 | } 214 | p.uiList = ls 215 | if p.Template == "" { 216 | if templateOpt := p.opts["template"]; templateOpt == nil { 217 | p.Template = "jira_ui_view" 218 | } else { 219 | p.Template = templateOpt.(string) 220 | } 221 | } 222 | innerWidth := uint(ui.TermWidth()) - 3 223 | if innerWidth < p.MaxWrapWidth { 224 | p.WrapWidth = innerWidth 225 | } else { 226 | p.WrapWidth = p.MaxWrapWidth 227 | } 228 | if p.apiBody == nil { 229 | p.apiBody, _ = FetchJiraTicket(p.TicketId) 230 | } 231 | p.cachedResults = WrapText(JiraTicketAsStrings(p.apiBody, p.Template), p.WrapWidth) 232 | ls.Items = p.cachedResults 233 | ls.ItemFgColor = ui.ColorYellow 234 | ls.Height = ui.TermHeight() - 2 235 | ls.Width = ui.TermWidth() 236 | ls.Border = true 237 | ls.BorderLabel = fmt.Sprintf("%s %s", p.TicketId, p.ticketTrailAsString()) 238 | ls.Y = 0 239 | p.statusBar.Create() 240 | p.commandBar.Create() 241 | p.Update() 242 | } 243 | -------------------------------------------------------------------------------- /ui_controls.go: -------------------------------------------------------------------------------- 1 | package jiraui 2 | 3 | import ( 4 | "os" 5 | 6 | ui "gopkg.in/gizak/termui.v2" 7 | ) 8 | 9 | func registerEventHandlers() { 10 | ui.Handle("/sys/kbd/", func(ev ui.Event) { 11 | handleAnyKey(ev) 12 | }) 13 | ui.Handle("/sys/kbd/C-c", func(ui.Event) { 14 | handleQuit() 15 | }) 16 | ui.Handle("/sys/wnd/resize", func(ui.Event) { 17 | handleResize() 18 | }) 19 | } 20 | 21 | func deregisterEventHandlers() { 22 | ui.Handle("/sys/kbd/", func(ev ui.Event) {}) 23 | ui.Handle("/sys/kbd/C-c", func(ev ui.Event) {}) 24 | ui.Handle("/sys/wnd/resize", func(ev ui.Event) {}) 25 | } 26 | 27 | func handleLabelViewKey() { 28 | switch page := currentPage.(type) { 29 | case *TicketListPage: 30 | q := new(LabelListPage) 31 | q.ActiveQuery = page.ActiveQuery 32 | previousPages = append(previousPages, currentPage) 33 | currentPage = q 34 | changePage() 35 | } 36 | } 37 | func handleQuit() { 38 | ui.Close() 39 | os.Exit(0) 40 | } 41 | 42 | func handleSortOrderKey() { 43 | switch currentPage.(type) { 44 | case *TicketListPage: 45 | q := new(SortOrderPage) 46 | previousPages = append(previousPages, currentPage) 47 | currentPage = q 48 | changePage() 49 | } 50 | } 51 | 52 | func handleRefreshKey() { 53 | if obj, ok := currentPage.(Refresher); ok { 54 | obj.Refresh() 55 | } 56 | } 57 | 58 | func handleEditKey() { 59 | if obj, ok := currentPage.(TicketEditer); ok { 60 | obj.EditTicket() 61 | } 62 | } 63 | 64 | func handleBackKey() { 65 | if obj, ok := currentPage.(GoBacker); ok { 66 | obj.GoBack() 67 | } else { 68 | ui.StopLoop() 69 | exitNow = true 70 | } 71 | } 72 | 73 | func handleResize() { 74 | changePage() 75 | } 76 | 77 | func handleSelectKey() { 78 | if obj, ok := currentPage.(ItemSelecter); ok { 79 | obj.SelectItem() 80 | } 81 | } 82 | 83 | func handleMarkTicketKey() { 84 | if obj, ok := currentPage.(RankSelector); ok { 85 | obj.MarkItemForRanking() 86 | } 87 | } 88 | 89 | func handleTopOfPageKey() { 90 | if obj, ok := currentPage.(PagePager); ok { 91 | obj.TopOfPage() 92 | obj.Update() 93 | } 94 | } 95 | 96 | func handleBottomOfPageKey() { 97 | if obj, ok := currentPage.(PagePager); ok { 98 | obj.BottomOfPage() 99 | obj.Update() 100 | } 101 | } 102 | 103 | func handleUpKey() { 104 | if obj, ok := currentPage.(PagePager); ok { 105 | obj.PreviousLine(1) 106 | obj.Update() 107 | } 108 | } 109 | 110 | func handleDownKey() { 111 | if obj, ok := currentPage.(PagePager); ok { 112 | obj.NextLine(1) 113 | obj.Update() 114 | } 115 | } 116 | 117 | func handlePageUpKey() { 118 | if obj, ok := currentPage.(PagePager); ok { 119 | obj.PreviousPage() 120 | obj.Update() 121 | } 122 | } 123 | 124 | func handlePageDownKey() { 125 | if obj, ok := currentPage.(PagePager); ok { 126 | obj.NextPage() 127 | obj.Update() 128 | } 129 | } 130 | 131 | func handleParaUpKey() { 132 | if obj, ok := currentPage.(PagePager); ok { 133 | obj.PreviousPara() 134 | obj.Update() 135 | } 136 | } 137 | 138 | func handleParaDownKey() { 139 | if obj, ok := currentPage.(PagePager); ok { 140 | obj.NextPara() 141 | obj.Update() 142 | } 143 | } 144 | 145 | func handleNextSearchKey() { 146 | if obj, ok := currentPage.(Searcher); ok { 147 | obj.Search() 148 | } 149 | } 150 | 151 | func handleNextTicketKey() { 152 | if obj, ok := currentPage.(NextTicketer); ok { 153 | obj.NextTicket() 154 | } 155 | } 156 | 157 | func handlePrevTicketKey() { 158 | if obj, ok := currentPage.(PrevTicketer); ok { 159 | obj.PrevTicket() 160 | } 161 | } 162 | 163 | func handleHelp() { 164 | previousPages = append(previousPages, currentPage) 165 | currentPage = helpPage 166 | changePage() 167 | } 168 | 169 | func handleNavigateKey(e ui.Event) { 170 | key := e.Data.(ui.EvtKbd).KeyStr 171 | switch key { 172 | case "L": 173 | handleLabelViewKey() 174 | case "S": 175 | handleSortOrderKey() 176 | case "C-r": 177 | handleRefreshKey() 178 | case "E": 179 | handleEditKey() 180 | case "w": 181 | args := []string{"add"} 182 | handleWatchCommand(args) 183 | case "W": 184 | args := []string{"remove"} 185 | handleWatchCommand(args) 186 | case "v": 187 | handleVoteCommand(true) 188 | case "V": 189 | handleVoteCommand(false) 190 | case "q": 191 | handleBackKey() 192 | case "": 193 | handleSelectKey() 194 | case "g": 195 | handleTopOfPageKey() 196 | case "G": 197 | handleBottomOfPageKey() 198 | case "": 199 | handlePageDownKey() 200 | case "C-f": 201 | handlePageDownKey() 202 | case "C-b": 203 | handlePageUpKey() 204 | case "}": 205 | handleParaDownKey() 206 | case "{": 207 | handleParaUpKey() 208 | case "": 209 | handleDownKey() 210 | case "": 211 | handleUpKey() 212 | case "j": 213 | handleDownKey() 214 | case "k": 215 | handleUpKey() 216 | case ":": 217 | handleCommandKey(e) 218 | case "/": 219 | handleCommandKey(e) 220 | case "?": 221 | handleCommandKey(e) 222 | case "n": 223 | handleNextSearchKey() 224 | case "N": 225 | handleNextTicketKey() 226 | case "P": 227 | handlePrevTicketKey() 228 | case "h": 229 | handleHelp() 230 | case "r": 231 | handleMarkTicketKey() 232 | } 233 | } 234 | 235 | func handleCommandKey(e ui.Event) { 236 | if obj, ok := currentPage.(PagePager); ok { 237 | if obj, ok := obj.(CommandBoxer); ok { 238 | obj.SetCommandMode(true) 239 | obj.CommandBar().Reset() 240 | handleAnyKey(e) 241 | } 242 | } 243 | } 244 | 245 | func handleEditBoxKey(obj EditPager, key string) { 246 | var str string 247 | switch { 248 | case len(key) == 1: 249 | str = key 250 | case key == "": 251 | str = ` ` 252 | case key == "": 253 | str = "\n" 254 | case key == "" || key == "C-8": 255 | // C-8 == ^? == backspace on a UK macbook 256 | obj.DeleteRuneBackward() 257 | obj.Update() 258 | return 259 | default: 260 | return 261 | } 262 | r := decodeTermuiKbdStringToRune(str) 263 | obj.InsertRune(r) 264 | obj.Update() 265 | return 266 | } 267 | 268 | func handleAnyKey(e ui.Event) { 269 | key := e.Data.(ui.EvtKbd).KeyStr 270 | if obj, ok := currentPage.(PagePager); ok && obj.IsPopulated() { 271 | if obj, ok := obj.(CommandBoxer); ok { 272 | if !obj.CommandMode() { 273 | handleNavigateKey(e) 274 | return 275 | } 276 | } else { 277 | handleNavigateKey(e) 278 | return 279 | } 280 | } 281 | 282 | if obj, ok := currentPage.(EditPager); ok { 283 | handleEditBoxKey(obj, key) 284 | return 285 | } 286 | 287 | if obj, ok := currentPage.(CommandBoxer); ok && obj.CommandMode() { 288 | cb := obj.CommandBar() 289 | if key == "" { 290 | cb.Submit() 291 | return 292 | } else if key == "" { 293 | cb.PreviousCommand() 294 | return 295 | } else if key == "" { 296 | cb.NextCommand() 297 | return 298 | } else { 299 | handleEditBoxKey(cb, key) 300 | return 301 | } 302 | } 303 | 304 | } 305 | -------------------------------------------------------------------------------- /run.go: -------------------------------------------------------------------------------- 1 | package jiraui 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/coryb/optigo" 8 | "github.com/op/go-logging" 9 | ui "gopkg.in/gizak/termui.v2" 10 | ) 11 | 12 | var exitNow = false 13 | 14 | type EditPager interface { 15 | DeleteRuneBackward() 16 | InsertRune(r rune) 17 | Update() 18 | Create() 19 | } 20 | 21 | type TicketCommander interface { 22 | ActiveTicketId() string 23 | Refresh() 24 | } 25 | 26 | type Searcher interface { 27 | SetSearch(string) 28 | Search() 29 | } 30 | 31 | type CommandBoxer interface { 32 | SetCommandMode(bool) 33 | ExecuteCommand() 34 | CommandMode() bool 35 | CommandBar() *CommandBar 36 | Update() 37 | } 38 | 39 | type NextTicketer interface { 40 | NextTicket() 41 | } 42 | 43 | type PrevTicketer interface { 44 | PrevTicket() 45 | } 46 | 47 | type GoBacker interface { 48 | GoBack() 49 | } 50 | 51 | type Refresher interface { 52 | Refresh() 53 | } 54 | 55 | type ItemSelecter interface { 56 | SelectItem() 57 | } 58 | 59 | type TicketEditer interface { 60 | EditTicket() 61 | } 62 | 63 | type TicketCommenter interface { 64 | CommentTicket() 65 | } 66 | 67 | type PagePager interface { 68 | NextLine(int) 69 | PreviousLine(int) 70 | NextPara() 71 | PreviousPara() 72 | NextPage() 73 | PreviousPage() 74 | TopOfPage() 75 | BottomOfPage() 76 | IsPopulated() bool 77 | Update() 78 | } 79 | 80 | type Navigable interface { 81 | Create() 82 | Update() 83 | Id() string 84 | } 85 | 86 | type RankSelector interface { 87 | MarkItemForRanking() 88 | } 89 | 90 | var currentPage Navigable 91 | var previousPages []Navigable 92 | 93 | var ticketQueryPage *QueryPage 94 | var helpPage *HelpPage 95 | var labelListPage *LabelListPage 96 | var sortOrderPage *SortOrderPage 97 | var passwordInputBox *PasswordInputBox 98 | var commandBar *CommandBar 99 | 100 | func changePage() { 101 | if currentPage == nil { 102 | currentPage = new(QueryPage) 103 | } 104 | switch currentPage.(type) { 105 | case *QueryPage: 106 | log.Debugf("changePage: QueryPage %s (%p)", currentPage.Id(), currentPage) 107 | currentPage.Create() 108 | case *TicketListPage: 109 | log.Debugf("changePage: TicketListPage %s (%p)", currentPage.Id(), currentPage) 110 | currentPage.Create() 111 | case *SortOrderPage: 112 | log.Debugf("changePage: SortOrderPage %s (%p)", currentPage.Id(), currentPage) 113 | currentPage.Create() 114 | case *LabelListPage: 115 | log.Debugf("changePage: LabelListPage %s (%p)", currentPage.Id(), currentPage) 116 | currentPage.Create() 117 | case *TicketShowPage: 118 | log.Debugf("changePage: TicketShowPage %s (%p)", currentPage.Id(), currentPage) 119 | currentPage.Create() 120 | case *HelpPage: 121 | log.Debugf("changePage: HelpPage %s (%p)", currentPage.Id(), currentPage) 122 | currentPage.Create() 123 | } 124 | } 125 | 126 | const LOG_MODULE = "jiraui" 127 | 128 | var ( 129 | log = logging.MustGetLogger(LOG_MODULE) 130 | format = "%{color}%{time:2006-01-02T15:04:05.000Z07:00} %{level:-5s} [%{shortfile}]%{color:reset} %{message}" 131 | ) 132 | 133 | var cliOpts map[string]interface{} 134 | 135 | func Run() { 136 | 137 | usage := func(ok bool) { 138 | printer := fmt.Printf 139 | if !ok { 140 | printer = func(format string, args ...interface{}) (int, error) { 141 | return fmt.Fprintf(os.Stderr, format, args...) 142 | } 143 | defer func() { 144 | os.Exit(1) 145 | }() 146 | } else { 147 | defer func() { 148 | os.Exit(0) 149 | }() 150 | } 151 | output := fmt.Sprintf(` 152 | Usage: 153 | jira-ui ls 154 | jira-ui ISSUE 155 | jira-ui 156 | 157 | General Options: 158 | -e --endpoint=URI URI to use for jira 159 | -l --log=FILE FILE to use for log (default /dev/null) 160 | -h --help Show this usage 161 | -u --user=USER Username to use for authenticaion 162 | -v --verbose Increase output logging 163 | --skiplogin Skip the login check. You must have a valid session token (eg via 'jira login') 164 | --version Print version 165 | 166 | Ticket View Options: 167 | -t --template=FILE Template file to use for viewing tickets 168 | -m --max_wrap=VAL Maximum word-wrap width when viewing ticket text (0 disables) 169 | 170 | Query Options: 171 | -q --query=JQL Jira Query Language expression for the search 172 | -f --queryfields=FIELDS Fields that are used in "list" view 173 | 174 | `) 175 | printer(output) 176 | } 177 | 178 | jiraCommands := map[string]string{ 179 | "list": "list", 180 | "ls": "list", 181 | "password": "password", 182 | "passwd": "password", 183 | } 184 | 185 | cliOpts = make(map[string]interface{}) 186 | cliOpts["log"] = "/dev/null" 187 | setopt := func(name string, value interface{}) { 188 | cliOpts[name] = value 189 | } 190 | 191 | logging.SetLevel(logging.NOTICE, "jira") 192 | logging.SetLevel(logging.NOTICE, LOG_MODULE) 193 | 194 | op := optigo.NewDirectAssignParser(map[string]interface{}{ 195 | "h|help": usage, 196 | "version": func() { 197 | fmt.Println(fmt.Sprintf("version: %s", VERSION)) 198 | os.Exit(0) 199 | }, 200 | "v|verbose+": func() { 201 | logging.SetLevel(logging.GetLevel(LOG_MODULE)+1, LOG_MODULE) 202 | }, 203 | "l|log=s": setopt, 204 | "u|user=s": setopt, 205 | "endpoint=s": setopt, 206 | "q|query=s": setopt, 207 | "f|queryfields=s": setopt, 208 | "t|template=s": setopt, 209 | "m|max_wrap=i": setopt, 210 | "skip_login": setopt, 211 | }) 212 | 213 | if err := op.ProcessAll(os.Args[1:]); err != nil { 214 | log.Errorf("%s", err) 215 | usage(false) 216 | } 217 | args := op.Args 218 | f, err := os.Create(cliOpts["log"].(string)) 219 | if err != nil { 220 | panic(err) 221 | } 222 | defer f.Close() 223 | backend := logging.NewLogBackend(f, "", 0) 224 | logging.SetBackend(backend) 225 | 226 | var command string 227 | if len(args) > 0 { 228 | if alias, ok := jiraCommands[args[0]]; ok { 229 | command = alias 230 | args = args[1:] 231 | } else { 232 | command = "view" 233 | args = args[0:] 234 | } 235 | } else { 236 | command = "toplevel" 237 | } 238 | 239 | requireArgs := func(count int) { 240 | if len(args) < count { 241 | log.Errorf("Not enough arguments. %d required, %d provided", count, len(args)) 242 | usage(false) 243 | } 244 | } 245 | 246 | if val, ok := cliOpts["skip_login"]; !ok || !val.(bool) { 247 | err = ensureLoggedIntoJira() 248 | if err != nil { 249 | log.Error("Login failed. Aborting") 250 | os.Exit(2) 251 | } 252 | } 253 | 254 | err = ui.Init() 255 | if err != nil { 256 | panic(err) 257 | } 258 | defer ui.Close() 259 | 260 | registerEventHandlers() 261 | 262 | helpPage = new(HelpPage) 263 | commandBar = new(CommandBar) 264 | 265 | switch command { 266 | case "list": 267 | if query := cliOpts["query"]; query == nil { 268 | log.Errorf("Must supply a --query option to %q", command) 269 | os.Exit(1) 270 | } else { 271 | p := new(TicketListPage) 272 | p.ActiveQuery.JQL = query.(string) 273 | p.ActiveQuery.Name = "adhoc" 274 | currentPage = p 275 | } 276 | case "view": 277 | requireArgs(1) 278 | p := new(TicketShowPage) 279 | p.TicketId = args[0] 280 | currentPage = p 281 | case "toplevel": 282 | currentPage = new(QueryPage) 283 | case "password": 284 | currentPage = new(PasswordInputBox) 285 | default: 286 | log.Errorf("Unknown command %s", command) 287 | os.Exit(1) 288 | } 289 | 290 | for exitNow != true { 291 | 292 | currentPage.Create() 293 | ui.Loop() 294 | 295 | } 296 | 297 | } 298 | -------------------------------------------------------------------------------- /command_bar_fragment.go: -------------------------------------------------------------------------------- 1 | package jiraui 2 | 3 | import ( 4 | "strings" 5 | ) 6 | 7 | type CommandBarFragment struct { 8 | commandBar *CommandBar 9 | commandMode bool 10 | } 11 | 12 | func (p *CommandBarFragment) ExecuteCommand() { 13 | command := string(p.commandBar.text) 14 | if command == "" { 15 | return 16 | } 17 | commandMode := string([]rune(command)[0]) 18 | switch commandMode { 19 | case "/": 20 | log.Debugf("Search down: %q", command) 21 | if obj, ok := currentPage.(Searcher); ok { 22 | obj.SetSearch(command) 23 | obj.Search() 24 | } 25 | case "?": 26 | log.Debugf("Search up: %q", command) 27 | if obj, ok := currentPage.(Searcher); ok { 28 | obj.SetSearch(command) 29 | obj.Search() 30 | } 31 | case ":": 32 | log.Debugf("Command: %q", command) 33 | handleCommand(command) 34 | } 35 | } 36 | 37 | func handleCommand(command string) { 38 | if len(command) < 2 { 39 | // must be :something 40 | return 41 | } 42 | fields := strings.Fields(string(command[1:])) 43 | action := fields[0] 44 | var args []string 45 | if len(fields) > 1 { 46 | args = fields[1:] 47 | } 48 | log.Debugf("handleCommand: action %q, args %s", action, args) 49 | switch { 50 | case action == "q" || action == "quit": 51 | handleQuit() 52 | case action == "create": 53 | handleCreateCommand(args) 54 | case action == "label" || action == "labels": 55 | handleLabelCommand(args) 56 | case action == "help": 57 | handleHelp() 58 | case action == "watch": 59 | handleWatchCommand(args) 60 | case action == "vote": 61 | handleVoteCommand(true) 62 | case action == "unvote": 63 | handleVoteCommand(false) 64 | case action == "assign": 65 | handleAssignCommand(args[0]) 66 | case action == "unassign": 67 | handleAssignCommand("-1") 68 | case action == "take": 69 | opts := getJiraOpts() 70 | handleAssignCommand(opts["user"].(string)) 71 | case action == "comment": 72 | if len(command) > 10 { 73 | handleCommentCommand(string(command[9:])) 74 | } 75 | case action == "shell": 76 | runShell() 77 | case action == "search" || action == "search-open" || action == "so": 78 | handleSearchOpen(args) 79 | case action == "search-all" || action == "sa": 80 | handleSearchAll(args) 81 | case action == "search-project-open" || action == "spo": 82 | handleSearchProjectOpen(args) 83 | case action == "search-project-all" || action == "spa": 84 | handleSearchProjectAll(args) 85 | case action == "query": 86 | n := len(":query ") 87 | if len(command) > n { 88 | handleQueryCommand("adhoc query", string(command[(n-1):])) 89 | } 90 | case action == "view": 91 | if len(args) > 0 { 92 | handleViewCommand(args[0]) 93 | } 94 | } 95 | } 96 | 97 | func handleCreateCommand(args []string) { 98 | if len(args) == 0 { 99 | return 100 | } 101 | project := args[0] 102 | summary := "" 103 | if len(args) > 1 { 104 | summary = strings.Join(args[1:], ` `) 105 | } 106 | runJiraCmdCreate(project, summary) 107 | } 108 | 109 | func handleLabelCommand(args []string) { 110 | log.Debugf("handleLabelCommand: args %s", args) 111 | if obj, ok := currentPage.(TicketCommander); ok { 112 | ticketId := obj.ActiveTicketId() 113 | if ticketId == "" || args == nil { 114 | return 115 | } 116 | action := "add" 117 | var labels []string 118 | switch args[0] { 119 | case "add": 120 | action = "add" 121 | if len(args) > 1 { 122 | labels = args[1:] 123 | } 124 | case "remove": 125 | action = "remove" 126 | if len(args) > 1 { 127 | labels = args[1:] 128 | } 129 | default: 130 | labels = args 131 | } 132 | runJiraCmdLabels(ticketId, action, labels) 133 | obj.Refresh() 134 | } 135 | } 136 | 137 | func handleCommentCommand(comment string) { 138 | log.Debugf("handleCommentCommand: comment %s", comment) 139 | if obj, ok := currentPage.(TicketCommander); ok { 140 | ticketId := obj.ActiveTicketId() 141 | if ticketId == "" || comment == "" { 142 | return 143 | } 144 | log.Debugf("handleCommentCommand: ticket: %s, comment %s", ticketId, comment) 145 | runJiraCmdCommentNoEditor(ticketId, comment) 146 | obj.Refresh() 147 | } 148 | } 149 | 150 | func handleAssignCommand(user string) { 151 | log.Debugf("handleAssignCommand: user %s", user) 152 | if obj, ok := currentPage.(TicketCommander); ok { 153 | ticketId := obj.ActiveTicketId() 154 | if ticketId == "" || user == "" { 155 | return 156 | } 157 | log.Debugf("handleAssignCommand: ticket: %s, user %s", ticketId, user) 158 | runJiraCmdAssign(ticketId, user) 159 | obj.Refresh() 160 | } 161 | } 162 | 163 | func handleViewCommand(ticket string) { 164 | log.Debugf("handleViewCommand: ticket %s", ticket) 165 | if ticket == "" { 166 | return 167 | } 168 | q := new(TicketShowPage) 169 | q.TicketId = ticket 170 | previousPages = append(previousPages, currentPage) 171 | currentPage = q 172 | changePage() 173 | } 174 | 175 | func handleSearchOpen(args []string) { 176 | if len(args) == 0 { 177 | return 178 | } 179 | query := `text ~ "` + strings.Join(args, ` `) + `" AND resolution = Unresolved` 180 | handleQueryCommand("so "+strings.Join(args, ` `), query) 181 | } 182 | 183 | func handleSearchAll(args []string) { 184 | if len(args) == 0 { 185 | return 186 | } 187 | query := `text ~ "` + strings.Join(args, ` `) + `"` 188 | handleQueryCommand("sa "+strings.Join(args, ` `), query) 189 | } 190 | 191 | func handleSearchProjectAll(args []string) { 192 | if len(args) < 2 { 193 | return 194 | } 195 | project := args[0] 196 | query := `project = ` + project + ` AND text ~ "` + strings.Join(args[1:], ` `) + `"` 197 | handleQueryCommand("spa "+strings.Join(args, ` `), query) 198 | } 199 | 200 | func handleSearchProjectOpen(args []string) { 201 | if len(args) < 2 { 202 | return 203 | } 204 | project := args[0] 205 | query := `project = ` + project + ` AND text ~ "` + strings.Join(args[1:], ` `) + `" AND resolution = Unresolved` 206 | handleQueryCommand("spo "+strings.Join(args, ` `), query) 207 | } 208 | 209 | func handleQueryCommand(name string, query string) { 210 | log.Debugf("handleQueryCommand: query %q", query) 211 | if query == "" { 212 | return 213 | } 214 | q := new(TicketListPage) 215 | q.ActiveQuery.Name = name 216 | q.ActiveQuery.JQL = query 217 | previousPages = append(previousPages, currentPage) 218 | currentPage = q 219 | changePage() 220 | } 221 | 222 | func handleVoteCommand(up bool) { 223 | log.Debugf("handleVoteCommand: up %q", up) 224 | if obj, ok := currentPage.(TicketCommander); ok { 225 | ticketId := obj.ActiveTicketId() 226 | if ticketId == "" { 227 | return 228 | } 229 | runJiraCmdVote(ticketId, up) 230 | obj.Refresh() 231 | } 232 | } 233 | 234 | func handleWatchCommand(args []string) { 235 | log.Debugf("handleWatchCommand: args %s", args) 236 | if obj, ok := currentPage.(TicketCommander); ok { 237 | ticketId := obj.ActiveTicketId() 238 | if ticketId == "" { 239 | return 240 | } 241 | log.Debugf("handleWatchCommand: ticket: %s, args %s", ticketId, args) 242 | if len(args) == 0 { 243 | runJiraCmdWatch(ticketId, "", false) // watch issue 244 | } else if args[0] == "add" { 245 | if len(args) > 1 { 246 | runJiraCmdWatch(ticketId, args[1], false) // add any user as watcher 247 | } else { 248 | runJiraCmdWatch(ticketId, "", false) // add self as watcher 249 | } 250 | } else if args[0] == "remove" { 251 | if len(args) > 1 { 252 | runJiraCmdWatch(ticketId, args[1], true) // remove any user as watcher 253 | } else { 254 | runJiraCmdWatch(ticketId, "", true) // remove self as watcher 255 | } 256 | } else { 257 | return 258 | } 259 | obj.Refresh() 260 | } 261 | } 262 | 263 | func (p *CommandBarFragment) SetCommandMode(mode bool) { 264 | p.commandMode = mode 265 | } 266 | 267 | func (p *CommandBarFragment) CommandMode() bool { 268 | return p.commandMode 269 | } 270 | 271 | func (p *CommandBarFragment) CommandBar() *CommandBar { 272 | return p.commandBar 273 | } 274 | -------------------------------------------------------------------------------- /utils.go: -------------------------------------------------------------------------------- 1 | package jiraui 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io/ioutil" 7 | "os" 8 | "os/exec" 9 | "regexp" 10 | "strings" 11 | 12 | "github.com/mitchellh/go-wordwrap" 13 | "gopkg.in/Netflix-Skunkworks/go-jira.v0" 14 | "gopkg.in/coryb/yaml.v2" 15 | ui "gopkg.in/gizak/termui.v2" 16 | ) 17 | 18 | func countLabelsFromQuery(query string) map[string]int { 19 | data, _ := runJiraQuery(query) 20 | return countLabelsFromQueryData(data) 21 | } 22 | 23 | func countLabelsFromQueryData(data interface{}) map[string]int { 24 | counts := make(map[string]int) 25 | issues := data.(map[string]interface{})["issues"].([]interface{}) 26 | for _, issue := range issues { 27 | issueLabels := issue.(map[string]interface{})["fields"].(map[string]interface{})["labels"] 28 | labels := issueLabels.([]interface{}) 29 | if len(labels) == 0 { 30 | // "NOT LABELLED" isn't a valid label, so no possible conflict here. 31 | counts["NOT LABELLED"] = counts["NOT LABELLED"] + 1 32 | } else { 33 | for _, v := range labels { 34 | label := v.(string) 35 | counts[label] = counts[label] + 1 36 | } 37 | } 38 | } 39 | return counts 40 | } 41 | 42 | func RunExternalCommand(fn func() error) error { 43 | log.Debugf("ShellOut() called with %q", fn) 44 | deregisterEventHandlers() 45 | ui.Clear() 46 | stty := exec.Command("stty", "-f", "/dev/tty", "echo", "opost") 47 | _ = stty.Run() 48 | err := fn() // magic happens 49 | stty = exec.Command("stty", "-f", "/dev/tty", "-echo", "-opost") 50 | _ = stty.Run() 51 | registerEventHandlers() 52 | if err != nil { 53 | return err 54 | } 55 | return nil 56 | } 57 | 58 | func runShell() { 59 | _ = RunExternalCommand( 60 | func() error { 61 | cmd := exec.Command("bash") 62 | cmd.Stdout, cmd.Stderr, cmd.Stdin = os.Stdout, os.Stderr, os.Stdin 63 | return cmd.Run() 64 | }) 65 | changePage() 66 | } 67 | 68 | func runJiraCmdEdit(ticketId string) { 69 | _ = RunExternalCommand( 70 | func() error { 71 | opts := getJiraOpts() 72 | c := jira.New(opts) 73 | return c.CmdEdit(ticketId) 74 | }) 75 | switch c := currentPage.(type) { 76 | case Refresher: 77 | c.Refresh() 78 | } 79 | changePage() 80 | } 81 | 82 | func runJiraCmdCreate(project string, summary string) { 83 | _ = RunExternalCommand( 84 | func() error { 85 | opts := getJiraOpts() 86 | opts["project"] = project 87 | opts["summary"] = summary 88 | c := jira.New(opts) 89 | return c.CmdCreate() 90 | }) 91 | switch c := currentPage.(type) { 92 | case Refresher: 93 | c.Refresh() 94 | } 95 | changePage() 96 | } 97 | 98 | func runJiraCmdCommentNoEditor(ticketId string, comment string) { 99 | opts := getJiraOpts() 100 | opts["comment"] = comment 101 | c := jira.New(opts) 102 | c.CmdComment(ticketId) 103 | } 104 | 105 | func runJiraCmdAssign(ticketId string, user string) { 106 | opts := getJiraOpts() 107 | c := jira.New(opts) 108 | c.CmdAssign(ticketId, user) 109 | } 110 | 111 | func runJiraCmdWatch(ticketId string, watcher string, remove bool) { 112 | opts := getJiraOpts() 113 | c := jira.New(opts) 114 | if watcher == "" { 115 | watcher = opts["user"].(string) 116 | } 117 | c.CmdWatch(ticketId, watcher, remove) 118 | } 119 | 120 | func runJiraCmdVote(ticketId string, up bool) { 121 | opts := getJiraOpts() 122 | c := jira.New(opts) 123 | c.CmdVote(ticketId, up) 124 | } 125 | 126 | func runJiraCmdLabels(ticketId string, action string, labels []string) { 127 | opts := getJiraOpts() 128 | c := jira.New(opts) 129 | err := c.CmdLabels(action, ticketId, labels) 130 | if err != nil { 131 | log.Errorf("Error writing labels: %q", err) 132 | } 133 | } 134 | 135 | func runJiraCmdRank(ticketId, targetId string, order jira.RankOrder) { 136 | opts := getJiraOpts() 137 | c := jira.New(opts) 138 | err := c.RankIssue(ticketId, targetId, order) 139 | if err != nil { 140 | log.Errorf("Error modifying issue rank: %q", err) 141 | } 142 | } 143 | 144 | func findTicketIdInString(line string) string { 145 | re := regexp.MustCompile(`[A-Z]{2,12}-[0-9]{1,6}`) 146 | re_too_long := regexp.MustCompile(`[A-Z]{13}-[0-9]{1,6}`) 147 | 148 | if re_too_long.MatchString(line) { 149 | return "" 150 | } 151 | 152 | return strings.TrimSpace(re.FindString(line)) 153 | } 154 | 155 | func runJiraQuery(query string) (interface{}, error) { 156 | opts := getJiraOpts() 157 | opts["query"] = query 158 | c := jira.New(opts) 159 | return c.FindIssues() 160 | } 161 | 162 | func JiraQueryAsStrings(query string, templateName string) []string { 163 | opts := getJiraOpts() 164 | opts["query"] = query 165 | c := jira.New(opts) 166 | data, _ := c.FindIssues() 167 | buf := new(bytes.Buffer) 168 | if templateName == "" { 169 | templateName = "jira_ui_list" 170 | } 171 | template := c.GetTemplate(templateName) 172 | if template == "" { 173 | template = default_list_template 174 | } 175 | jira.RunTemplate(template, data, buf) 176 | return strings.Split(strings.TrimSpace(buf.String()), "\n") 177 | } 178 | 179 | func FetchJiraTicket(id string) (interface{}, error) { 180 | opts := getJiraOpts() 181 | c := jira.New(opts) 182 | return c.ViewIssue(id) 183 | } 184 | 185 | func JiraTicketAsStrings(data interface{}, templateName string) []string { 186 | opts := getJiraOpts() 187 | c := jira.New(opts) 188 | buf := new(bytes.Buffer) 189 | template := c.GetTemplate(templateName) 190 | log.Debugf("JiraTicketsAsStrings: template = %q", template) 191 | if template == "" { 192 | template = strings.Replace(default_view_template, "ENDPOINT", opts["endpoint"].(string), 1) 193 | } 194 | jira.RunTemplate(template, data, buf) 195 | return strings.Split(strings.TrimSpace(buf.String()), "\n") 196 | } 197 | 198 | func HelpTextAsStrings(data interface{}, templateName string) []string { 199 | opts := getJiraOpts() 200 | c := jira.New(opts) 201 | buf := new(bytes.Buffer) 202 | template := c.GetTemplate(templateName) 203 | if template == "" { 204 | template = default_help_template 205 | } 206 | log.Debugf("HelpTextAsStrings: template = %q", template) 207 | jira.RunTemplate(template, data, buf) 208 | return strings.Split(strings.TrimSpace(buf.String()), "\n") 209 | } 210 | 211 | func WrapText(lines []string, maxWidth uint) []string { 212 | out := make([]string, 0) 213 | insideNoformatBlock := false 214 | insideCodeBlock := false 215 | for _, line := range lines { 216 | if matched, _ := regexp.MatchString(`^\s+\{code`, line); matched { 217 | insideCodeBlock = !insideCodeBlock 218 | } else if strings.TrimSpace(line) == "{noformat}" { 219 | insideNoformatBlock = !insideNoformatBlock 220 | } 221 | if maxWidth == 0 || uint(len(line)) < maxWidth || insideCodeBlock || insideNoformatBlock { 222 | out = append(out, line) 223 | continue 224 | } 225 | if matched, _ := regexp.MatchString(`^[a-z_]+:\s`, line); matched { 226 | // don't futz with single line field+value. 227 | // If they are too long, that's their fault. 228 | out = append(out, line) 229 | continue 230 | } 231 | // wrap text, but preserve indenting 232 | re := regexp.MustCompile(`^\s*`) 233 | indenting := re.FindString(line) 234 | wrappedLines := strings.Split(wordwrap.WrapString(line, maxWidth-uint(len(indenting))), "\n") 235 | indentedWrappedLines := make([]string, len(wrappedLines)) 236 | for i, wl := range wrappedLines { 237 | if i == 0 { 238 | // first line already has the indent 239 | indentedWrappedLines[i] = wl 240 | } else { 241 | indentedWrappedLines[i] = indenting + wl 242 | } 243 | } 244 | out = append(out, indentedWrappedLines...) 245 | } 246 | return out 247 | } 248 | 249 | func parseYaml(file string, v map[string]interface{}) { 250 | if fh, err := ioutil.ReadFile(file); err == nil { 251 | log.Debugf("Parsing YAML file: %s", file) 252 | yaml.Unmarshal(fh, &v) 253 | } 254 | } 255 | 256 | func loadConfigs(opts map[string]interface{}) { 257 | paths := jira.FindParentPaths(".jira.d/jira-ui-config.yml") 258 | paths = append(jira.FindParentPaths(".jira.d/config.yml"), paths...) 259 | paths = append([]string{"/etc/go-jira-ui.yml", "/etc/go-jira.yml"}, paths...) 260 | 261 | // iterate paths in reverse 262 | for i := len(paths) - 1; i >= 0; i-- { 263 | file := paths[i] 264 | if _, err := os.Stat(file); err == nil { 265 | tmp := make(map[string]interface{}) 266 | parseYaml(file, tmp) 267 | for k, v := range tmp { 268 | if _, ok := opts[k]; !ok { 269 | log.Debugf("Setting %q to %#v from %s", k, v, file) 270 | opts[k] = v 271 | } 272 | } 273 | } 274 | } 275 | } 276 | 277 | func doLogin(opts map[string]interface{}) error { 278 | c := jira.New(opts) 279 | fmt.Printf("Logging in as %s:\n", opts["user"]) 280 | return c.CmdLogin() 281 | } 282 | 283 | func ensureLoggedIntoJira() error { 284 | homeDir := os.Getenv("HOME") 285 | opts := getJiraOpts() 286 | testSessionQuery := fmt.Sprintf("reporter = %s", opts["user"]) 287 | if _, err := os.Stat(fmt.Sprintf("%s/.jira.d/cookies.js", homeDir)); err != nil { 288 | return doLogin(opts) 289 | } else if data, err := runJiraQuery(testSessionQuery); err != nil { 290 | return doLogin(opts) 291 | } else if val, ok := data.(map[string]interface{})["errorMessages"]; ok { 292 | if len(val.([]interface{})) > 0 { 293 | return doLogin(opts) 294 | } 295 | } 296 | return nil 297 | } 298 | 299 | func getJiraOpts() map[string]interface{} { 300 | user := os.Getenv("USER") 301 | home := os.Getenv("HOME") 302 | defaultQueryFields := "summary,created,updated,priority,status,reporter,assignee,labels" 303 | defaultSort := "priority asc, created" 304 | defaultMaxResults := 1000 305 | 306 | opts := make(map[string]interface{}) 307 | defaults := map[string]interface{}{ 308 | "user": user, 309 | "endpoint": os.Getenv("JIRA_ENDPOINT"), 310 | "queryfields": defaultQueryFields, 311 | "directory": fmt.Sprintf("%s/.jira.d/templates", home), 312 | "sort": defaultSort, 313 | "max_results": defaultMaxResults, 314 | "method": "GET", 315 | "quiet": true, 316 | } 317 | 318 | for k, v := range cliOpts { 319 | if _, ok := opts[k]; !ok { 320 | log.Debugf("Setting %q to %#v from cli options", k, v) 321 | opts[k] = v 322 | } 323 | } 324 | 325 | loadConfigs(opts) 326 | for k, v := range defaults { 327 | if _, ok := opts[k]; !ok { 328 | log.Debugf("Setting %q to %#v from defaults", k, v) 329 | opts[k] = v 330 | } 331 | } 332 | return opts 333 | } 334 | 335 | func lastLineDisplayed(ls *ScrollableList, firstLine int, correction int) int { 336 | return firstLine + ls.Height - correction 337 | } 338 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2015 Mike Pountney 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | 203 | --------------------------------------------------------------------------------