├── .github ├── FUNDING.yml ├── codecov.yml ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md └── workflows │ ├── golangci-lint.yml │ ├── tests.yml │ ├── check-commit.yml │ ├── release-precheck.yml │ └── release.yml ├── codecov.yml ├── demo.gif ├── fjira.png ├── demo_filters.png ├── demo_issue.png ├── demo_first_run.gif ├── demo_board_view.png ├── demo_custom_jql.png ├── internal ├── comments │ ├── comment.go │ └── parser.go ├── ui │ ├── const.go │ ├── goto.go │ ├── goto_test.go │ ├── navigation_test.go │ ├── text_writer_view.go │ └── messages.go ├── app │ ├── test.go │ ├── math.go │ ├── view.go │ ├── open_link.go │ ├── text.go │ ├── flash.go │ ├── spinner_test.go │ ├── open_link_test.go │ ├── text_box_test.go │ ├── action_bar_test.go │ ├── action_bar_item.go │ ├── spinner.go │ ├── text_test.go │ ├── goto_test.go │ ├── goto.go │ ├── flash_test.go │ ├── confirmation.go │ ├── draw.go │ ├── colors_test.go │ ├── confirmation_test.go │ ├── text_box.go │ ├── colors.go │ ├── fuzzy_find_test.go │ ├── app_test.go │ └── action_bar.go ├── filters │ ├── formatter.go │ ├── goto.go │ ├── formatter_test.go │ ├── goto_test.go │ └── filters_search.go ├── workspaces │ ├── current.go │ ├── goto.go │ ├── settings_test.go │ ├── switch_workspace.go │ └── settings.go ├── issues │ ├── open.go │ ├── jql_builder.go │ ├── goto_test.go │ ├── jql_builder_test.go │ ├── formatter.go │ ├── goto.go │ └── jql_search.go ├── boards │ ├── formatter.go │ ├── goto.go │ └── goto_test.go ├── projects │ ├── goto.go │ ├── formatter.go │ ├── goto_test.go │ ├── search_projects_test.go │ └── search_projects.go ├── labels │ ├── goto.go │ ├── goto_test.go │ ├── add_label.go │ └── add_label_test.go ├── users │ ├── goto.go │ ├── user_formatter.go │ ├── fetch.go │ ├── goto_test.go │ ├── fuzzy.go │ ├── fuzzy_test.go │ └── user_assign.go ├── statuses │ ├── goto.go │ ├── formatter.go │ ├── goto_test.go │ ├── status_change.go │ └── status_change_test.go ├── jira │ ├── jira_mock.go │ ├── jira_comment.go │ ├── jira_comment_test.go │ ├── jira_auth_interceptor.go │ ├── jira_project_statuses.go │ ├── jira_filters.go │ ├── jira_request.go │ ├── transport.go │ ├── jira_assignee.go │ ├── jira_assignee_test.go │ ├── jira_issue.go │ ├── jira_projects.go │ ├── jira_request_test.go │ ├── jira_user.go │ ├── jira_labels.go │ ├── jira_labels_test.go │ ├── jira_search.go │ ├── jira_transitions.go │ ├── jira_transitions_test.go │ ├── jira.go │ ├── jira_projects_test.go │ ├── jira_user_test.go │ ├── jira_search_test.go │ └── jira_issue_test.go ├── os │ ├── user_home_dir.go │ └── user_home_dir_test.go └── fjira │ ├── fjira_test.go │ └── fjira.go ├── scripts ├── postremove.sh └── docker-compose-jira-server.yml ├── .gitignore ├── .golangci.yml ├── assets ├── fjira.txt ├── fjira.yaml └── colors.yml ├── bats └── test.bats ├── docs ├── fjira_jql.md ├── fjira_version.md ├── fjira_[issueKey].md ├── fjira_workspace.md └── fjira.md ├── cmd └── fjira-cli │ ├── commands │ ├── version.go │ ├── version_test.go │ ├── jql.go │ ├── filters.go │ ├── issue.go │ ├── jql_test.go │ ├── root_test.go │ ├── filters_test.go │ ├── workspace_test.go │ ├── issue_test.go │ ├── workspace.go │ └── root.go │ └── main.go ├── features └── projects.feature ├── Makefile ├── go.mod └── CONTRIBUTING.md /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | buy_me_a_coffee: mateuszkulawik 2 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | ignore: 2 | - "internal/jira/jira_mock.go" 3 | -------------------------------------------------------------------------------- /demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mk-5/fjira/HEAD/demo.gif -------------------------------------------------------------------------------- /fjira.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mk-5/fjira/HEAD/fjira.png -------------------------------------------------------------------------------- /demo_filters.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mk-5/fjira/HEAD/demo_filters.png -------------------------------------------------------------------------------- /demo_issue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mk-5/fjira/HEAD/demo_issue.png -------------------------------------------------------------------------------- /demo_first_run.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mk-5/fjira/HEAD/demo_first_run.gif -------------------------------------------------------------------------------- /demo_board_view.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mk-5/fjira/HEAD/demo_board_view.png -------------------------------------------------------------------------------- /demo_custom_jql.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mk-5/fjira/HEAD/demo_custom_jql.png -------------------------------------------------------------------------------- /internal/comments/comment.go: -------------------------------------------------------------------------------- 1 | package comments 2 | 3 | type Comment struct { 4 | Body string 5 | Title string 6 | Lines int 7 | } 8 | -------------------------------------------------------------------------------- /internal/ui/const.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | const ( 4 | TableColumnPadding = 2 5 | MaxSummaryColWidth = 45 6 | MaxStatusColWidth = 12 7 | ) 8 | -------------------------------------------------------------------------------- /scripts/postremove.sh: -------------------------------------------------------------------------------- 1 | rm -rf "$HOME/.fjira" 2 | for dir in /home/*/.fjira 3 | do 4 | if [ -d "$dir" ]; then 5 | echo "Removing $dir" 6 | rm -rf $dir 7 | fi; 8 | done 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | .vscode/ 3 | !.gitkeep 4 | build/ 5 | fjira 6 | !fjira/ 7 | vendor 8 | gopath 9 | out 10 | node_modules/ 11 | coverage.out 12 | coverage.html 13 | coverage.txt 14 | 15 | dist/ 16 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | run: 2 | timeout: 4m 3 | issues-exit-code: 1 4 | tests: true 5 | skip-dirs: 6 | - out 7 | skip-dirs-use-default: false 8 | modules-download-mode: mod 9 | allow-parallel-runners: true 10 | go: '1.18' 11 | -------------------------------------------------------------------------------- /assets/fjira.txt: -------------------------------------------------------------------------------- 1 | ____ __________ ___ 2 | / __/ / / _/ __ \/ | 3 | / /___ / // // /_/ / /| | 4 | / __/ /_/ // // _, _/ ___ | 5 | /_/ \____/___/_/ |_/_/ |_| 6 | 7 | fJIRA is a command line tool for Jira. 8 | 9 | Usage: fjira [OPTIONS] 10 | -------------------------------------------------------------------------------- /bats/test.bats: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bats 2 | 3 | assert_status() { 4 | local expect 5 | expect="$1" 6 | 7 | [ "${status}" -eq "${expect}" ] 8 | } 9 | 10 | 11 | @test "should run&show help" { 12 | run out/bin/fjira --help 13 | assert_status 0 14 | } 15 | -------------------------------------------------------------------------------- /internal/app/test.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import "github.com/gdamore/tcell/v2" 4 | 5 | func InitTestApp(s tcell.SimulationScreen) *App { 6 | if s == nil { 7 | s = tcell.NewSimulationScreen("utf-8") 8 | } 9 | app := CreateNewAppWithScreen(s) 10 | return app 11 | } 12 | -------------------------------------------------------------------------------- /.github/codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | status: 3 | project: 4 | default: 5 | target: 70% 6 | threshold: 60% 7 | base: auto 8 | if_ci_failed: error #success, failure, error, ignore 9 | informational: false 10 | only_pulls: false 11 | -------------------------------------------------------------------------------- /internal/filters/formatter.go: -------------------------------------------------------------------------------- 1 | package filters 2 | 3 | import "github.com/mk-5/fjira/internal/jira" 4 | 5 | func FormatFilters(filters []jira.Filter) []string { 6 | s := make([]string, 0, len(filters)) 7 | for _, filter := range filters { 8 | s = append(s, filter.Name) 9 | } 10 | return s 11 | } 12 | -------------------------------------------------------------------------------- /internal/workspaces/current.go: -------------------------------------------------------------------------------- 1 | package workspaces 2 | 3 | func GetCurrent() (string, error) { 4 | s := NewUserHomeSettingsStorage() 5 | w, err := s.ReadCurrentWorkspace() 6 | if err != nil { 7 | return "", err 8 | } 9 | if w == "" { 10 | return "default", nil 11 | } 12 | return w, nil 13 | } 14 | -------------------------------------------------------------------------------- /internal/ui/goto.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "github.com/mk-5/fjira/internal/app" 5 | ) 6 | 7 | func RegisterGoTo() { 8 | app.RegisterGoto("text-writer", func(args ...interface{}) { 9 | a := args[0].(*TextWriterArgs) 10 | view := NewTextWriterView(a) 11 | app.GetApp().SetView(view) 12 | }) 13 | } 14 | -------------------------------------------------------------------------------- /internal/workspaces/goto.go: -------------------------------------------------------------------------------- 1 | package workspaces 2 | 3 | import "github.com/mk-5/fjira/internal/app" 4 | 5 | func RegisterGoTo() { 6 | app.RegisterGoto("workspaces-switch", func(args ...interface{}) { 7 | switchWorkspaceView := NewSwitchWorkspaceView() 8 | app.GetApp().SetView(switchWorkspaceView) 9 | }) 10 | } 11 | -------------------------------------------------------------------------------- /internal/issues/open.go: -------------------------------------------------------------------------------- 1 | package issues 2 | 3 | import ( 4 | "fmt" 5 | "github.com/mk-5/fjira/internal/app" 6 | "github.com/mk-5/fjira/internal/jira" 7 | ) 8 | 9 | func OpenIssueInBrowser(i *jira.Issue, api jira.Api) { 10 | jiraUrl := api.GetApiUrl() 11 | app.OpenLink(fmt.Sprintf("%s/browse/%s", jiraUrl, i.Key)) 12 | } 13 | -------------------------------------------------------------------------------- /docs/fjira_jql.md: -------------------------------------------------------------------------------- 1 | ## fjira jql 2 | 3 | Search using custom-jql 4 | 5 | ``` 6 | fjira jql [flags] 7 | ``` 8 | 9 | ### Options 10 | 11 | ``` 12 | -h, --help help for jql 13 | ``` 14 | 15 | ### SEE ALSO 16 | 17 | * [fjira](fjira.md) - A fuzzy jira tui application 18 | 19 | ###### Auto generated by spf13/cobra on 16-Sep-2023 20 | -------------------------------------------------------------------------------- /internal/boards/formatter.go: -------------------------------------------------------------------------------- 1 | package boards 2 | 3 | import "github.com/mk-5/fjira/internal/jira" 4 | 5 | func FormatJiraBoards(boards []jira.BoardItem) []string { 6 | formatted := make([]string, 0, len(boards)) 7 | for _, board := range boards { 8 | formatted = append(formatted, board.Name) 9 | } 10 | return formatted 11 | } 12 | -------------------------------------------------------------------------------- /docs/fjira_version.md: -------------------------------------------------------------------------------- 1 | ## fjira version 2 | 3 | Print the version number of fjira 4 | 5 | ``` 6 | fjira version [flags] 7 | ``` 8 | 9 | ### Options 10 | 11 | ``` 12 | -h, --help help for version 13 | ``` 14 | 15 | ### SEE ALSO 16 | 17 | * [fjira](fjira.md) - A fuzzy jira tui application 18 | 19 | ###### Auto generated by spf13/cobra on 16-Sep-2023 20 | -------------------------------------------------------------------------------- /docs/fjira_[issueKey].md: -------------------------------------------------------------------------------- 1 | ## fjira [issueKey] 2 | 3 | Open jira issue directly from the cli 4 | 5 | ``` 6 | fjira [issueKey] [flags] 7 | ``` 8 | 9 | ### Options 10 | 11 | ``` 12 | -h, --help help for [issueKey] 13 | ``` 14 | 15 | ### SEE ALSO 16 | 17 | * [fjira](fjira.md) - A fuzzy jira tui application 18 | 19 | ###### Auto generated by spf13/cobra on 16-Sep-2023 20 | -------------------------------------------------------------------------------- /internal/app/math.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | func MinInt(x, y int) int { 4 | if x < y { 5 | return x 6 | } 7 | return y 8 | } 9 | 10 | func MaxInt(x, y int) int { 11 | if x > y { 12 | return x 13 | } 14 | return y 15 | } 16 | 17 | func ClampInt(v, min, max int) int { 18 | if v > max { 19 | return max 20 | } 21 | if v < min { 22 | return min 23 | } 24 | return v 25 | } 26 | -------------------------------------------------------------------------------- /internal/projects/goto.go: -------------------------------------------------------------------------------- 1 | package projects 2 | 3 | import ( 4 | "github.com/mk-5/fjira/internal/app" 5 | "github.com/mk-5/fjira/internal/jira" 6 | ) 7 | 8 | func RegisterGoto() { 9 | app.RegisterGoto("projects", func(args ...interface{}) { 10 | api := args[0].(jira.Api) 11 | projectsView := NewProjectsSearchView(api) 12 | app.GetApp().SetView(projectsView) 13 | }) 14 | } 15 | -------------------------------------------------------------------------------- /cmd/fjira-cli/commands/version.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "fmt" 5 | "github.com/spf13/cobra" 6 | ) 7 | 8 | func GetVersionCmd(v string) *cobra.Command { 9 | return &cobra.Command{ 10 | Use: "version", 11 | Short: "Print the version number of fjira", 12 | Run: func(cmd *cobra.Command, args []string) { 13 | fmt.Printf("fjira version: %s", v) 14 | }, 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /internal/filters/goto.go: -------------------------------------------------------------------------------- 1 | package filters 2 | 3 | import ( 4 | "github.com/mk-5/fjira/internal/app" 5 | "github.com/mk-5/fjira/internal/jira" 6 | ) 7 | 8 | func RegisterGoTo() { 9 | app.RegisterGoto("filters", func(args ...interface{}) { 10 | defer app.GetApp().PanicRecover() 11 | api := args[0].(jira.Api) 12 | view := NewFiltersView(api) 13 | app.GetApp().SetView(view) 14 | }) 15 | } 16 | -------------------------------------------------------------------------------- /scripts/docker-compose-jira-server.yml: -------------------------------------------------------------------------------- 1 | version: '3.4' 2 | 3 | # Jira Server Version - just for local testing 4 | services: 5 | jira-server: 6 | image: atlassian/jira-software 7 | ports: 8 | - 8080:8080 9 | volumes: 10 | - /var/atlassian/application-data/jira 11 | deploy: 12 | resources: 13 | limits: 14 | cpus: "4.0" 15 | memory: 4096M 16 | -------------------------------------------------------------------------------- /assets/fjira.yaml: -------------------------------------------------------------------------------- 1 | current: default 2 | workspaces: 3 | default: 4 | jiraRestUrl: https://my-jira.atlassian.net 5 | jiraToken: change_me_to_token 6 | jiraUsername: change_me_to_user 7 | jiraTokenType: api token 8 | local: 9 | jiraRestUrl: http://localhost:8080 10 | jiraToken: change_me_to_token 11 | jiraUsername: change_me_to_user 12 | jiraTokenType: personal token 13 | -------------------------------------------------------------------------------- /internal/app/view.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import "github.com/gdamore/tcell/v2" 4 | 5 | type View interface { 6 | Init() 7 | Destroy() 8 | } 9 | 10 | type Drawable interface { 11 | Draw(screen tcell.Screen) 12 | } 13 | 14 | type Resizable interface { 15 | Resize(screenX, screenY int) 16 | } 17 | 18 | type System interface { 19 | Update() 20 | } 21 | 22 | type KeyListener interface { 23 | HandleKeyEvent(keyEvent *tcell.EventKey) 24 | } 25 | -------------------------------------------------------------------------------- /docs/fjira_workspace.md: -------------------------------------------------------------------------------- 1 | ## fjira workspace 2 | 3 | Switch workspace to another 4 | 5 | ``` 6 | fjira workspace [flags] 7 | ``` 8 | 9 | ### Options 10 | 11 | ``` 12 | --edit string Edit workspace 13 | -h, --help help for workspace 14 | --new string Create a new workspace 15 | ``` 16 | 17 | ### SEE ALSO 18 | 19 | * [fjira](fjira.md) - A fuzzy jira tui application 20 | 21 | ###### Auto generated by spf13/cobra on 16-Sep-2023 22 | -------------------------------------------------------------------------------- /internal/labels/goto.go: -------------------------------------------------------------------------------- 1 | package labels 2 | 3 | import ( 4 | "github.com/mk-5/fjira/internal/app" 5 | "github.com/mk-5/fjira/internal/jira" 6 | ) 7 | 8 | func RegisterGoTo() { 9 | app.RegisterGoto("labels-add", func(args ...interface{}) { 10 | issue := args[0].(*jira.Issue) 11 | goBackFn := args[1].(func()) 12 | api := args[2].(jira.Api) 13 | 14 | commentView := NewAddLabelView(issue, goBackFn, api) 15 | app.GetApp().SetView(commentView) 16 | }) 17 | } 18 | -------------------------------------------------------------------------------- /internal/users/goto.go: -------------------------------------------------------------------------------- 1 | package users 2 | 3 | import ( 4 | "github.com/mk-5/fjira/internal/app" 5 | "github.com/mk-5/fjira/internal/jira" 6 | ) 7 | 8 | func RegisterGoTo() { 9 | app.RegisterGoto("users-assign", func(args ...interface{}) { 10 | issue := args[0].(*jira.Issue) 11 | goBackFn := args[1].(func()) 12 | api := args[2].(jira.Api) 13 | assignChangeView := NewAssignChangeView(issue, goBackFn, api) 14 | app.GetApp().SetView(assignChangeView) 15 | }) 16 | } 17 | -------------------------------------------------------------------------------- /internal/statuses/goto.go: -------------------------------------------------------------------------------- 1 | package statuses 2 | 3 | import ( 4 | "github.com/mk-5/fjira/internal/app" 5 | "github.com/mk-5/fjira/internal/jira" 6 | ) 7 | 8 | func RegisterGoTo() { 9 | app.RegisterGoto("status-change", func(args ...interface{}) { 10 | issue := args[0].(*jira.Issue) 11 | goBackFn := args[1].(func()) 12 | api := args[2].(jira.Api) 13 | 14 | statusChangeView := NewStatusChangeView(issue, goBackFn, api) 15 | app.GetApp().SetView(statusChangeView) 16 | }) 17 | } 18 | -------------------------------------------------------------------------------- /internal/users/user_formatter.go: -------------------------------------------------------------------------------- 1 | package users 2 | 3 | import ( 4 | "fmt" 5 | "github.com/mk-5/fjira/internal/jira" 6 | ) 7 | 8 | func FormatJiraUser(user *jira.User) string { 9 | return fmt.Sprintf("%s <%s>", user.DisplayName, user.EmailAddress) 10 | } 11 | 12 | func FormatJiraUsers(users []jira.User) []string { 13 | formatted := make([]string, 0, len(users)) 14 | for _, user := range users { 15 | formatted = append(formatted, FormatJiraUser(&user)) 16 | } 17 | return formatted 18 | } 19 | -------------------------------------------------------------------------------- /internal/projects/formatter.go: -------------------------------------------------------------------------------- 1 | package projects 2 | 3 | import ( 4 | "fmt" 5 | "github.com/mk-5/fjira/internal/jira" 6 | ) 7 | 8 | func FormatJiraProject(project *jira.Project) string { 9 | return fmt.Sprintf("[%s] %s", project.Key, project.Name) 10 | } 11 | 12 | func FormatJiraProjects(projects []jira.Project) []string { 13 | formatted := make([]string, 0, len(projects)) 14 | for _, project := range projects { 15 | formatted = append(formatted, FormatJiraProject(&project)) 16 | } 17 | return formatted 18 | } 19 | -------------------------------------------------------------------------------- /cmd/fjira-cli/commands/version_test.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "github.com/stretchr/testify/assert" 5 | "testing" 6 | ) 7 | 8 | func TestGetVersionCmd(t *testing.T) { 9 | tests := []struct { 10 | name string 11 | }{ 12 | {"should create&execute VersionCmd"}, 13 | } 14 | for _, tt := range tests { 15 | t.Run(tt.name, func(t *testing.T) { 16 | // when 17 | cmd := GetVersionCmd("abc") 18 | 19 | // then 20 | assert.NotNil(t, cmd) 21 | 22 | // and when 23 | err := cmd.Execute() 24 | 25 | // then 26 | assert.Nil(t, err) 27 | }) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /internal/statuses/formatter.go: -------------------------------------------------------------------------------- 1 | package statuses 2 | 3 | import "github.com/mk-5/fjira/internal/jira" 4 | 5 | func FormatJiraStatuses(statuses []jira.IssueStatus) []string { 6 | formatted := make([]string, 0, len(statuses)) 7 | for _, status := range statuses { 8 | formatted = append(formatted, status.Name) 9 | } 10 | return formatted 11 | } 12 | 13 | func FormatJiraTransitions(statuses []jira.IssueTransition) []string { 14 | formatted := make([]string, 0, len(statuses)) 15 | for _, status := range statuses { 16 | formatted = append(formatted, status.Name) 17 | } 18 | return formatted 19 | } 20 | -------------------------------------------------------------------------------- /cmd/fjira-cli/commands/jql.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "github.com/mk-5/fjira/internal/fjira" 5 | "github.com/mk-5/fjira/internal/workspaces" 6 | "github.com/spf13/cobra" 7 | ) 8 | 9 | func GetJqlCmd() *cobra.Command { 10 | return &cobra.Command{ 11 | Use: "jql", 12 | Short: "Search using custom JQL queries", 13 | Run: func(cmd *cobra.Command, args []string) { 14 | s := cmd.Context().Value(CtxWorkspaceSettings).(*workspaces.WorkspaceSettings) 15 | f := fjira.CreateNewFjira(s) 16 | defer f.Close() 17 | f.Run(&fjira.CliArgs{ 18 | JqlMode: true, 19 | }) 20 | }, 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /cmd/fjira-cli/commands/filters.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "github.com/mk-5/fjira/internal/fjira" 5 | "github.com/mk-5/fjira/internal/workspaces" 6 | "github.com/spf13/cobra" 7 | ) 8 | 9 | func GetFiltersCmd() *cobra.Command { 10 | return &cobra.Command{ 11 | Use: "filters", 12 | Short: "Search using Jira filters", 13 | Run: func(cmd *cobra.Command, args []string) { 14 | s := cmd.Context().Value(CtxWorkspaceSettings).(*workspaces.WorkspaceSettings) 15 | f := fjira.CreateNewFjira(s) 16 | defer f.Close() 17 | f.Run(&fjira.CliArgs{ 18 | FiltersMode: true, 19 | }) 20 | }, 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /internal/app/open_link.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os/exec" 7 | "runtime" 8 | ) 9 | 10 | func OpenLink(url string) { 11 | err := openLinkForSystem(runtime.GOOS, url) 12 | if err != nil { 13 | log.Fatal(err) 14 | } 15 | } 16 | 17 | func openLinkForSystem(system string, url string) error { 18 | switch system { 19 | case "linux": 20 | return exec.Command("xdg-open", url).Start() 21 | case "windows": 22 | return exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start() 23 | case "darwin": 24 | return exec.Command("open", url).Start() 25 | default: 26 | return fmt.Errorf("unsupported platform") 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /cmd/fjira-cli/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "github.com/mk-5/fjira/cmd/fjira-cli/commands" 6 | "os" 7 | ) 8 | 9 | var ( 10 | version = "dev" 11 | ) 12 | 13 | func main() { 14 | initCli() 15 | } 16 | 17 | func initCli() { 18 | rootCmd := commands.GetRootCmd() 19 | 20 | rootCmd.AddCommand(commands.GetIssueCmd()) 21 | rootCmd.AddCommand(commands.GetWorkspaceCmd()) 22 | rootCmd.AddCommand(commands.GetJqlCmd()) 23 | rootCmd.AddCommand(commands.GetFiltersCmd()) 24 | rootCmd.AddCommand(commands.GetVersionCmd(version)) 25 | 26 | if err := rootCmd.Execute(); err != nil { 27 | _, _ = fmt.Fprintln(os.Stderr, err) 28 | os.Exit(1) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /internal/app/text.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import "github.com/gdamore/tcell/v2" 4 | 5 | type Text struct { 6 | x int 7 | y int 8 | style tcell.Style 9 | text string 10 | } 11 | 12 | func NewText(x, y int, style tcell.Style, text string) *Text { 13 | return &Text{ 14 | x: x, y: y, style: style, text: text, 15 | } 16 | } 17 | 18 | func (t *Text) Draw(screen tcell.Screen) { 19 | row := t.y 20 | col := t.x 21 | for _, r := range t.text { 22 | if r == '\n' { 23 | row++ 24 | col = t.x 25 | continue 26 | } 27 | screen.SetContent(col, row, r, nil, t.style) 28 | col++ 29 | } 30 | } 31 | 32 | func (t *Text) ChangeText(newText string) { 33 | t.text = newText 34 | } 35 | -------------------------------------------------------------------------------- /internal/users/fetch.go: -------------------------------------------------------------------------------- 1 | package users 2 | 3 | import ( 4 | "github.com/mk-5/fjira/internal/app" 5 | "github.com/mk-5/fjira/internal/jira" 6 | ) 7 | 8 | type RecordsProvider interface { 9 | FetchUsers(projectKey string, query string) []jira.User 10 | } 11 | 12 | type apiRecordsProvider struct { 13 | api jira.Api 14 | } 15 | 16 | func NewApiRecordsProvider(api jira.Api) RecordsProvider { 17 | return &apiRecordsProvider{ 18 | api: api, 19 | } 20 | } 21 | 22 | func (r *apiRecordsProvider) FetchUsers(projectKey string, query string) []jira.User { 23 | us, err := r.api.FindUsersWithQuery(projectKey, query) 24 | if err != nil { 25 | app.Error(err.Error()) 26 | } 27 | return us 28 | } 29 | -------------------------------------------------------------------------------- /cmd/fjira-cli/commands/issue.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "github.com/mk-5/fjira/internal/fjira" 5 | "github.com/mk-5/fjira/internal/workspaces" 6 | "github.com/spf13/cobra" 7 | ) 8 | 9 | func GetIssueCmd() *cobra.Command { 10 | return &cobra.Command{ 11 | Use: "[issueKey]", 12 | Short: "Open a Jira issue directly from the CLI", 13 | Args: cobra.ExactArgs(1), 14 | Run: func(cmd *cobra.Command, args []string) { 15 | s := cmd.Context().Value(CtxWorkspaceSettings).(*workspaces.WorkspaceSettings) 16 | issueKey := args[0] 17 | f := fjira.CreateNewFjira(s) 18 | defer f.Close() 19 | f.Run(&fjira.CliArgs{ 20 | IssueKey: issueKey, 21 | }) 22 | }, 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /internal/filters/formatter_test.go: -------------------------------------------------------------------------------- 1 | package filters 2 | 3 | import ( 4 | "github.com/mk-5/fjira/internal/jira" 5 | "github.com/stretchr/testify/assert" 6 | "testing" 7 | ) 8 | 9 | func TestFormatFilters(t *testing.T) { 10 | type args struct { 11 | filters []jira.Filter 12 | } 13 | tests := []struct { 14 | name string 15 | args args 16 | want []string 17 | }{ 18 | {"should format filters", 19 | args{filters: []jira.Filter{{Name: "Test ABC"}}}, 20 | []string{"Test ABC"}}, 21 | } 22 | for _, tt := range tests { 23 | t.Run(tt.name, func(t *testing.T) { 24 | assert.Equalf(t, tt.want, FormatFilters(tt.args.filters), "FormatFilters(%v)", tt.args.filters) 25 | }) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /features/projects.feature: -------------------------------------------------------------------------------- 1 | Feature: project selection 2 | The center point of Jira is Jira Project. Fjira user needs to select a project 3 | in order to do some actions with tickets. 4 | 5 | Scenario: Open workspace creation 6 | Given environment without fjira configured 7 | When run fjira 8 | Then fjir_should_open_workspace_creation 9 | 10 | Scenario: Open fjira and select project 11 | Given projects fuzzy find is up&running 12 | When project is selected 13 | Then fjira should open project view 14 | 15 | Scenario: Open project directly from terminal 16 | Given CLI argument with project key is present 17 | When fjira started 18 | Then fjira should open project view 19 | -------------------------------------------------------------------------------- /internal/jira/jira_mock.go: -------------------------------------------------------------------------------- 1 | package jira 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | ) 7 | 8 | func NewJiraApiMock(handler func(w http.ResponseWriter, r *http.Request)) Api { 9 | return NewJiraApiMockWithTokenType(handler, ApiToken) 10 | } 11 | 12 | func NewJiraApiMockWithTokenType(handler func(w http.ResponseWriter, r *http.Request), tokenType JiraTokenType) Api { 13 | stubServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 14 | if handler != nil { 15 | handler(w, r) 16 | return 17 | } 18 | w.WriteHeader(200) 19 | w.Write([]byte("")) //nolint:errcheck 20 | })) 21 | api, err := NewApi(stubServer.URL, "test", "test", tokenType) 22 | if err != nil { 23 | panic(err) 24 | } 25 | return api 26 | } 27 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Desktop (please complete the following information):** 11 | - OS: [e.g. MacOS Big Sur] 12 | - Terminal [e.g. iTerm, Linux Terminal] 13 | - Architecture: [e.g. x86, ARM, Apple] 14 | - Fjira version: [eg. 0.0.0] 15 | 16 | **Describe the bug** 17 | A clear and concise description of what the bug is. 18 | 19 | **To Reproduce** 20 | Steps to reproduce the behavior: 21 | 1. Go to '...' 22 | 2. Click on '....' 23 | 3. See error 24 | 25 | **Expected behavior** 26 | A clear and concise description of what you expected to happen. 27 | 28 | **Additional notes** 29 | Everything that could help to explain your problem. Ex. Screenshots 30 | -------------------------------------------------------------------------------- /internal/jira/jira_comment.go: -------------------------------------------------------------------------------- 1 | package jira 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "strings" 7 | ) 8 | 9 | type Comment struct { 10 | Author User `json:"author"` 11 | Body string `json:"body"` 12 | Created string `json:"created"` 13 | } 14 | 15 | type commentRequestBody struct { 16 | Body string `json:"body"` 17 | } 18 | 19 | const ( 20 | DoCommentIssueRestPath = "/rest/api/2/issue/%s/comment" 21 | ) 22 | 23 | func (api *httpApi) DoComment(issueId string, commentBody string) error { 24 | jsonBody, err := json.Marshal(&commentRequestBody{ 25 | Body: commentBody, 26 | }) 27 | if err != nil { 28 | return err 29 | } 30 | _, err = api.jiraRequest("POST", fmt.Sprintf(DoCommentIssueRestPath, issueId), &nilParams{}, strings.NewReader(string(jsonBody))) 31 | if err != nil { 32 | return err 33 | } 34 | return nil 35 | } 36 | -------------------------------------------------------------------------------- /internal/comments/parser.go: -------------------------------------------------------------------------------- 1 | package comments 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "github.com/mk-5/fjira/internal/app" 7 | "github.com/mk-5/fjira/internal/jira" 8 | ) 9 | 10 | // TODO - could be optimized a bit 11 | func ParseCommentsFromIssue(issue *jira.Issue, limitX, limitY int) []Comment { 12 | cs := make([]Comment, 0, 100) 13 | var commentsBuffer bytes.Buffer 14 | if len(issue.Fields.Comment.Comments) > 0 { 15 | for _, comment := range issue.Fields.Comment.Comments { 16 | title := fmt.Sprintf("%s, %s", comment.Created, comment.Author.DisplayName) 17 | body := fmt.Sprintf("\n%s", comment.Body) 18 | lines := app.DrawTextLimited(nil, 0, 0, limitX, limitY, app.DefaultStyle(), comment.Body) + 2 19 | cs = append(cs, Comment{ 20 | Title: title, 21 | Body: body, 22 | Lines: lines, 23 | }) 24 | commentsBuffer.Reset() 25 | } 26 | } 27 | return cs 28 | } 29 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | BINARY_DIR=out/bin 2 | BINARY_NAME=${BINARY_DIR}/fjira 3 | 4 | all: clean install test build 5 | 6 | build_run: clean build run 7 | 8 | install: 9 | go mod vendor 10 | 11 | build: 12 | mkdir -p ${BINARY_DIR} 13 | go build -o ${BINARY_NAME} cmd/fjira-cli/main.go 14 | chmod +x ${BINARY_NAME} 15 | 16 | build_windows: 17 | mkdir -p ${BINARY_DIR} 18 | GOOS=windows GOARCH=amd64 go build -o ${BINARY_NAME}.exe cmd/fjira-cli/main.go 19 | chmod +x ${BINARY_NAME}.exe 20 | 21 | run: 22 | ./${BINARY_NAME} 23 | 24 | test: 25 | go test ./internal/... 26 | 27 | test_coverage: 28 | go test -coverpkg=./... -covermode=count -coverprofile=coverage.out ./internal/... 29 | go tool cover -html=coverage.out -o=coverage.html 30 | go tool cover -func coverage.out 31 | 32 | release: 33 | goreleaser release --skip-publish --snapshot --rm-dist 34 | 35 | clean: 36 | rm -rf ${BINARY_DIR} 37 | rm -rf dist 38 | -------------------------------------------------------------------------------- /internal/jira/jira_comment_test.go: -------------------------------------------------------------------------------- 1 | package jira 2 | 3 | import ( 4 | "net/http" 5 | "testing" 6 | ) 7 | 8 | func Test_httpJiraApi_DoComment(t *testing.T) { 9 | type args struct { 10 | issueId string 11 | commentBody string 12 | } 13 | tests := []struct { 14 | name string 15 | args args 16 | wantErr bool 17 | }{ 18 | {"should do comment without error", 19 | args{commentBody: "Lorem ipsum", issueId: "ABC-123"}, 20 | false, 21 | }, 22 | } 23 | for _, tt := range tests { 24 | t.Run(tt.name, func(t *testing.T) { 25 | api := NewJiraApiMock(func(w http.ResponseWriter, r *http.Request) { 26 | w.WriteHeader(200) 27 | w.Write([]byte(``)) //nolint:errcheck 28 | }) 29 | if err := api.DoComment(tt.args.issueId, tt.args.commentBody); (err != nil) != tt.wantErr { 30 | t.Errorf("DoComment() error = %v, wantErr %v", err, tt.wantErr) 31 | } 32 | }) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /internal/app/flash.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | ) 7 | 8 | func Error(message string) { 9 | app := GetApp() 10 | errorMessage := fmt.Sprintf("Error! -%s", message) 11 | errorStyle := DefaultStyle().Foreground(Color("alerts.error.foreground")).Background(Color("alerts.error.background")) 12 | errorBox := NewTextBox(app.ScreenX/2-len(errorMessage)/2, app.ScreenY-1, errorStyle, errorStyle, errorMessage) 13 | GetApp().AddFlash(errorBox, 5*time.Second) 14 | } 15 | 16 | func Success(message string) { 17 | app := GetApp() 18 | successMessage := fmt.Sprintf("Success! %s", message) 19 | successStyle := DefaultStyle().Foreground(Color("alerts.success.foreground")).Background(Color("alerts.success.background")) 20 | successBox := NewTextBox(app.ScreenX/2-len(successMessage)/2, app.ScreenY-1, successStyle, successStyle, successMessage) 21 | GetApp().AddFlash(successBox, 3*time.Second) 22 | } 23 | -------------------------------------------------------------------------------- /cmd/fjira-cli/commands/jql_test.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "context" 5 | "github.com/mk-5/fjira/internal/app" 6 | "github.com/mk-5/fjira/internal/workspaces" 7 | "github.com/stretchr/testify/assert" 8 | "testing" 9 | "time" 10 | ) 11 | 12 | func TestGetJqlCmd(t *testing.T) { 13 | tests := []struct { 14 | name string 15 | }{ 16 | {"should create&execute JqlCmd"}, 17 | } 18 | for _, tt := range tests { 19 | t.Run(tt.name, func(t *testing.T) { 20 | // when 21 | cmd := GetJqlCmd() 22 | 23 | // then 24 | assert.NotNil(t, cmd) 25 | 26 | // and when 27 | var err error 28 | go func() { 29 | err = cmd.ExecuteContext(context.WithValue(context.TODO(), CtxWorkspaceSettings, &workspaces.WorkspaceSettings{})) 30 | }() //nolint:errcheck 31 | for app.GetApp() == nil { 32 | <-time.After(50 * time.Millisecond) 33 | } 34 | 35 | // then 36 | assert.Nil(t, err) 37 | }) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /cmd/fjira-cli/commands/root_test.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "context" 5 | "github.com/mk-5/fjira/internal/app" 6 | "github.com/mk-5/fjira/internal/workspaces" 7 | "github.com/stretchr/testify/assert" 8 | "testing" 9 | "time" 10 | ) 11 | 12 | func TestGetRootCmd(t *testing.T) { 13 | tests := []struct { 14 | name string 15 | }{ 16 | {"should create&execute RootCmd"}, 17 | } 18 | for _, tt := range tests { 19 | t.Run(tt.name, func(t *testing.T) { 20 | // when 21 | cmd := GetRootCmd() 22 | 23 | // then 24 | assert.NotNil(t, cmd) 25 | 26 | // and when 27 | var err error 28 | go func() { 29 | err = cmd.ExecuteContext(context.WithValue(context.TODO(), CtxWorkspaceSettings, &workspaces.WorkspaceSettings{})) 30 | }() //nolint:errcheck 31 | for app.GetApp() == nil { 32 | <-time.After(50 * time.Millisecond) 33 | } 34 | 35 | // then 36 | assert.Nil(t, err) 37 | }) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /internal/jira/jira_auth_interceptor.go: -------------------------------------------------------------------------------- 1 | package jira 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | ) 7 | 8 | const ( 9 | Authorization = "Authorization" 10 | XAtlassianToken = "X-Atlassian-Token" 11 | ) 12 | 13 | type AuthType string 14 | 15 | const ( 16 | Basic AuthType = "Basic" 17 | Bearer AuthType = "Bearer" 18 | ) 19 | 20 | type authInterceptor struct { 21 | core http.RoundTripper 22 | authType AuthType 23 | token string 24 | } 25 | 26 | func (a *authInterceptor) RoundTrip(r *http.Request) (*http.Response, error) { 27 | defer func() { 28 | if r.Body != nil { 29 | _ = r.Body.Close() 30 | } 31 | }() 32 | newRequest := a.modifyRequest(r) 33 | return a.core.RoundTrip(newRequest) 34 | } 35 | 36 | func (a *authInterceptor) modifyRequest(r *http.Request) *http.Request { 37 | r.Header.Set(Authorization, fmt.Sprintf("%s %s", a.authType, a.token)) 38 | r.Header.Set(XAtlassianToken, "no-check") 39 | return r 40 | } 41 | -------------------------------------------------------------------------------- /internal/app/spinner_test.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "bytes" 5 | "github.com/gdamore/tcell/v2" 6 | "github.com/stretchr/testify/assert" 7 | "testing" 8 | ) 9 | 10 | func TestSpinnerTCell_Draw(t1 *testing.T) { 11 | tests := []struct { 12 | name string 13 | }{ 14 | {"should draw the spinner"}, 15 | } 16 | for _, tt := range tests { 17 | t1.Run(tt.name, func(t1 *testing.T) { 18 | // given 19 | screen := tcell.NewSimulationScreen("utf-8") 20 | _ = screen.Init() //nolint:errcheck 21 | defer screen.Fini() 22 | spin := NewSimpleSpinner() 23 | spin.text = "LOADING" 24 | 25 | // when 26 | spin.Draw(screen) 27 | 28 | // then 29 | var buffer bytes.Buffer 30 | contents, x, y := screen.GetContents() 31 | screen.Show() 32 | for i := 0; i < x*y; i++ { 33 | buffer.Write(contents[i].Bytes) 34 | } 35 | result := buffer.String() 36 | assert.Contains(t1, result, "LOADING") 37 | }) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /cmd/fjira-cli/commands/filters_test.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "context" 5 | "github.com/mk-5/fjira/internal/app" 6 | "github.com/mk-5/fjira/internal/workspaces" 7 | "github.com/stretchr/testify/assert" 8 | "testing" 9 | "time" 10 | ) 11 | 12 | func TestGetFiltersCmd(t *testing.T) { 13 | tests := []struct { 14 | name string 15 | }{ 16 | {"should create&execute FiltersCmd"}, 17 | } 18 | for _, tt := range tests { 19 | t.Run(tt.name, func(t *testing.T) { 20 | // when 21 | cmd := GetFiltersCmd() 22 | 23 | // then 24 | assert.NotNil(t, cmd) 25 | 26 | // and when 27 | var err error 28 | go func() { 29 | err = cmd.ExecuteContext(context.WithValue(context.TODO(), CtxWorkspaceSettings, &workspaces.WorkspaceSettings{})) 30 | }() //nolint:errcheck 31 | for app.GetApp() == nil { 32 | <-time.After(50 * time.Millisecond) 33 | } 34 | 35 | // then 36 | assert.Nil(t, err) 37 | }) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /cmd/fjira-cli/commands/workspace_test.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "context" 5 | "github.com/mk-5/fjira/internal/app" 6 | "github.com/mk-5/fjira/internal/workspaces" 7 | "github.com/stretchr/testify/assert" 8 | "testing" 9 | "time" 10 | ) 11 | 12 | func TestGetWorkspaceCmd(t *testing.T) { 13 | tests := []struct { 14 | name string 15 | }{ 16 | {"should create&execute WorkspaceCmd"}, 17 | } 18 | for _, tt := range tests { 19 | t.Run(tt.name, func(t *testing.T) { 20 | // when 21 | cmd := GetWorkspaceCmd() 22 | 23 | // then 24 | assert.NotNil(t, cmd) 25 | 26 | // and when 27 | var err error 28 | go func() { 29 | err = cmd.ExecuteContext(context.WithValue(context.TODO(), CtxWorkspaceSettings, &workspaces.WorkspaceSettings{})) 30 | }() //nolint:errcheck 31 | for app.GetApp() == nil { 32 | <-time.After(50 * time.Millisecond) 33 | } 34 | 35 | // then 36 | assert.Nil(t, err) 37 | }) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /internal/app/open_link_test.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import "testing" 4 | 5 | func TestOpenLink(t *testing.T) { 6 | tests := []struct { 7 | name string 8 | }{ 9 | {"should run OpenLink func without error"}, 10 | } 11 | for _, tt := range tests { 12 | t.Run(tt.name, func(t *testing.T) { 13 | OpenLink("/dev/null") 14 | }) 15 | } 16 | } 17 | 18 | func TestOpenLink_forSystem(t *testing.T) { 19 | type args struct { 20 | system string 21 | } 22 | tests := []struct { 23 | name string 24 | args args 25 | }{ 26 | {"should run OpenLink func for system", args{system: "windows"}}, 27 | {"should run OpenLink func for system", args{system: "linux"}}, 28 | {"should run OpenLink func for system", args{system: "darwin"}}, 29 | {"should run OpenLink func for system", args{system: "unknown"}}, 30 | } 31 | for _, tt := range tests { 32 | t.Run(tt.name, func(t *testing.T) { 33 | _ = openLinkForSystem(tt.args.system, "/dev/null") 34 | }) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /internal/ui/goto_test.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "github.com/mk-5/fjira/internal/app" 5 | assert2 "github.com/stretchr/testify/assert" 6 | "testing" 7 | ) 8 | 9 | func Test_goIntoValidScreen(t *testing.T) { 10 | RegisterGoTo() 11 | app.InitTestApp(nil) 12 | 13 | type args struct { 14 | gotoMethod func() 15 | viewPredicate func() bool 16 | } 17 | tests := []struct { 18 | name string 19 | args args 20 | }{ 21 | {"should switch view into text writer view", args{ 22 | gotoMethod: func() { app.GoTo("text-writer", &TextWriterArgs{}) }, 23 | viewPredicate: func() bool { 24 | _, ok := app.GetApp().CurrentView().(*TextWriterView) 25 | return ok 26 | }, 27 | }}, 28 | } 29 | for _, tt := range tests { 30 | t.Run(tt.name, func(t *testing.T) { 31 | // when 32 | tt.args.gotoMethod() 33 | 34 | // then 35 | ok := tt.args.viewPredicate() 36 | assert2.New(t).True(ok, "Current view is invalid.") 37 | }) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /internal/ui/navigation_test.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "github.com/stretchr/testify/assert" 5 | "testing" 6 | ) 7 | 8 | func TestCreateNavigationBars(t *testing.T) { 9 | tests := []struct { 10 | name string 11 | supplier func() interface{} 12 | }{ 13 | {"should create issue bottom bar", func() interface{} { 14 | return CreateBottomLeftBar() 15 | }}, 16 | {"should create cancel bar item", func() interface{} { 17 | return NewCancelBarItem() 18 | }}, 19 | {"should create new save bar item", func() interface{} { 20 | return NewSaveBarItem() 21 | }}, 22 | {"should create new YES change bar item", func() interface{} { 23 | return NewYesBarItem() 24 | }}, 25 | {"should create new OPEN bar item", func() interface{} { 26 | return NewOpenBarItem() 27 | }}, 28 | } 29 | for _, tt := range tests { 30 | t.Run(tt.name, func(t *testing.T) { 31 | assert.NotNilf(t, tt.supplier(), "CreateCommentBarItem()") 32 | }) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /cmd/fjira-cli/commands/issue_test.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "context" 5 | "github.com/mk-5/fjira/internal/app" 6 | "github.com/mk-5/fjira/internal/workspaces" 7 | "github.com/stretchr/testify/assert" 8 | "testing" 9 | "time" 10 | ) 11 | 12 | func TestGetIssueCmd(t *testing.T) { 13 | tests := []struct { 14 | name string 15 | }{ 16 | {"should create&execute IssueCmd"}, 17 | } 18 | for _, tt := range tests { 19 | t.Run(tt.name, func(t *testing.T) { 20 | // when 21 | cmd := GetIssueCmd() 22 | 23 | // then 24 | assert.NotNil(t, cmd) 25 | 26 | // and when 27 | cmd.SetArgs([]string{"ABC-123"}) 28 | var err error 29 | go func() { 30 | err = cmd.ExecuteContext(context.WithValue(context.TODO(), CtxWorkspaceSettings, &workspaces.WorkspaceSettings{})) 31 | }() //nolint:errcheck 32 | for app.GetApp() == nil { 33 | <-time.After(50 * time.Millisecond) 34 | } 35 | 36 | // then 37 | assert.Nil(t, err) 38 | }) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /internal/filters/goto_test.go: -------------------------------------------------------------------------------- 1 | package filters 2 | 3 | import ( 4 | "github.com/mk-5/fjira/internal/app" 5 | "github.com/mk-5/fjira/internal/jira" 6 | assert2 "github.com/stretchr/testify/assert" 7 | "testing" 8 | ) 9 | 10 | func TestGoIntoFilters(t *testing.T) { 11 | app.InitTestApp(nil) 12 | RegisterGoTo() 13 | 14 | type args struct { 15 | gotoMethod func() 16 | viewPredicate func() bool 17 | } 18 | tests := []struct { 19 | name string 20 | args args 21 | }{ 22 | {"should switch view into filters view", args{ 23 | gotoMethod: func() { app.GoTo("filters", jira.NewJiraApiMock(nil)) }, 24 | viewPredicate: func() bool { 25 | return app.CurrentScreenName() == "filters" 26 | }, 27 | }}, 28 | } 29 | for _, tt := range tests { 30 | t.Run(tt.name, func(t *testing.T) { 31 | // when 32 | tt.args.gotoMethod() 33 | 34 | // then 35 | ok := tt.args.viewPredicate() 36 | assert2.New(t).True(ok, "Current view is invalid.") 37 | }) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /internal/projects/goto_test.go: -------------------------------------------------------------------------------- 1 | package projects 2 | 3 | import ( 4 | "github.com/mk-5/fjira/internal/app" 5 | "github.com/mk-5/fjira/internal/jira" 6 | assert2 "github.com/stretchr/testify/assert" 7 | "testing" 8 | ) 9 | 10 | func TestGoIntoProjectsSearch(t *testing.T) { 11 | app.InitTestApp(nil) 12 | RegisterGoto() 13 | type args struct { 14 | gotoMethod func() 15 | viewPredicate func() bool 16 | } 17 | tests := []struct { 18 | name string 19 | args args 20 | }{ 21 | {"should switch view into search projects view", args{ 22 | gotoMethod: func() { app.GoTo("projects", jira.NewJiraApiMock(nil)) }, 23 | viewPredicate: func() bool { 24 | return app.CurrentScreenName() == "projects" 25 | }, 26 | }}, 27 | } 28 | for _, tt := range tests { 29 | t.Run(tt.name, func(t *testing.T) { 30 | // when 31 | tt.args.gotoMethod() 32 | 33 | // then 34 | ok := tt.args.viewPredicate() 35 | assert2.New(t).True(ok, "Current view is invalid.") 36 | }) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /internal/users/goto_test.go: -------------------------------------------------------------------------------- 1 | package users 2 | 3 | import ( 4 | "github.com/mk-5/fjira/internal/app" 5 | "github.com/mk-5/fjira/internal/jira" 6 | assert2 "github.com/stretchr/testify/assert" 7 | "testing" 8 | ) 9 | 10 | func TestGoIntoChangeAssignment(t *testing.T) { 11 | app.InitTestApp(nil) 12 | RegisterGoTo() 13 | 14 | type args struct { 15 | gotoMethod func() 16 | viewPredicate func() bool 17 | } 18 | tests := []struct { 19 | name string 20 | args args 21 | }{ 22 | {"should switch view into assignment change view", args{ 23 | gotoMethod: func() { app.GoTo("users-assign", &jira.Issue{}, func() {}, jira.NewJiraApiMock(nil)) }, 24 | viewPredicate: func() bool { 25 | return app.CurrentScreenName() == "users-assign" 26 | }, 27 | }}, 28 | } 29 | for _, tt := range tests { 30 | t.Run(tt.name, func(t *testing.T) { 31 | // when 32 | tt.args.gotoMethod() 33 | 34 | // then 35 | ok := tt.args.viewPredicate() 36 | assert2.New(t).True(ok, "Current view is invalid.") 37 | }) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /docs/fjira.md: -------------------------------------------------------------------------------- 1 | ## fjira 2 | 3 | A fuzzy jira tui application 4 | 5 | ### Synopsis 6 | 7 | Fjira is a powerful terminal user interface (TUI) application designed to streamline your Jira workflow. 8 | With its fuzzy-find capabilities, it simplifies the process of searching and accessing Jira issues, 9 | making it easier than ever to locate and manage your tasks and projects efficiently. 10 | Say goodbye to manual searching and hello to increased productivity with fjira. 11 | 12 | ``` 13 | fjira [flags] 14 | ``` 15 | 16 | ### Options 17 | 18 | ``` 19 | -h, --help help for fjira 20 | -p, --project string Open project directly from cli 21 | ``` 22 | 23 | ### SEE ALSO 24 | 25 | * [fjira [issueKey]](fjira_[issueKey].md) - Open jira issue directly from the cli 26 | * [fjira jql](fjira_jql.md) - Search using custom-jql 27 | * [fjira version](fjira_version.md) - Print the version number of fjira 28 | * [fjira workspace](fjira_workspace.md) - Switch workspace to another 29 | 30 | ###### Auto generated by spf13/cobra on 16-Sep-2023 31 | -------------------------------------------------------------------------------- /internal/users/fuzzy.go: -------------------------------------------------------------------------------- 1 | package users 2 | 3 | import ( 4 | "github.com/mk-5/fjira/internal/app" 5 | "github.com/mk-5/fjira/internal/jira" 6 | "github.com/mk-5/fjira/internal/ui" 7 | ) 8 | 9 | const ( 10 | typeaheadSearchThreshold = 100 11 | ) 12 | 13 | func NewFuzzyFind(projectKey string, api jira.Api) (*app.FuzzyFind, *[]jira.User) { 14 | var us []jira.User 15 | var prevQuery string 16 | provider := NewApiRecordsProvider(api) 17 | return app.NewFuzzyFindWithProvider(ui.MessageSelectUser, func(query string) []string { 18 | // it searches up to {typeaheadThreshold} records using typeahead - then it do regular fuzzy-find 19 | if len(us) > 0 && len(us) < typeaheadSearchThreshold && len(query) > len(prevQuery) { 20 | return FormatJiraUsers(us) 21 | } 22 | prevQuery = query 23 | app.GetApp().Loading(true) 24 | us = provider.FetchUsers(projectKey, query) 25 | app.GetApp().Loading(false) 26 | us = append(us, jira.User{DisplayName: ui.MessageAll}) 27 | usersStrings := FormatJiraUsers(us) 28 | return usersStrings 29 | }), &us 30 | } 31 | -------------------------------------------------------------------------------- /internal/statuses/goto_test.go: -------------------------------------------------------------------------------- 1 | package statuses 2 | 3 | import ( 4 | "github.com/mk-5/fjira/internal/app" 5 | "github.com/mk-5/fjira/internal/jira" 6 | assert2 "github.com/stretchr/testify/assert" 7 | "testing" 8 | ) 9 | 10 | func TestGoIntoChangeStatus(t *testing.T) { 11 | app.InitTestApp(nil) 12 | RegisterGoTo() 13 | type args struct { 14 | gotoMethod func() 15 | viewPredicate func() bool 16 | } 17 | tests := []struct { 18 | name string 19 | args args 20 | }{ 21 | {"should switch view into change status view", args{ 22 | gotoMethod: func() { app.GoTo("status-change", &jira.Issue{}, func() {}, jira.NewJiraApiMock(nil)) }, 23 | viewPredicate: func() bool { 24 | _, ok := app.GetApp().CurrentView().(*statusChangeView) 25 | return ok 26 | }, 27 | }}, 28 | } 29 | for _, tt := range tests { 30 | t.Run(tt.name, func(t *testing.T) { 31 | // when 32 | tt.args.gotoMethod() 33 | 34 | // then 35 | ok := tt.args.viewPredicate() 36 | assert2.New(t).True(ok, "Current view is invalid.") 37 | }) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /internal/labels/goto_test.go: -------------------------------------------------------------------------------- 1 | package labels 2 | 3 | import ( 4 | "github.com/mk-5/fjira/internal/app" 5 | "github.com/mk-5/fjira/internal/jira" 6 | assert2 "github.com/stretchr/testify/assert" 7 | "testing" 8 | ) 9 | 10 | func TestGoIntoAddLabelView(t *testing.T) { 11 | app.InitTestApp(nil) 12 | RegisterGoTo() 13 | 14 | type args struct { 15 | gotoMethod func() 16 | viewPredicate func() bool 17 | } 18 | tests := []struct { 19 | name string 20 | args args 21 | }{ 22 | {"should switch view into add label view", args{ 23 | gotoMethod: func() { 24 | app.GoTo("labels-add", &jira.Issue{}, func() {}, jira.NewJiraApiMock(nil)) 25 | }, 26 | viewPredicate: func() bool { 27 | _, ok := app.GetApp().CurrentView().(*addLabelView) 28 | return ok 29 | }, 30 | }}, 31 | } 32 | for _, tt := range tests { 33 | t.Run(tt.name, func(t *testing.T) { 34 | // when 35 | tt.args.gotoMethod() 36 | 37 | // then 38 | ok := tt.args.viewPredicate() 39 | assert2.New(t).True(ok, "Current view is invalid.") 40 | }) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /internal/os/user_home_dir.go: -------------------------------------------------------------------------------- 1 | package os 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "runtime" 7 | ) 8 | 9 | func SetUserHomeDir(dir string) error { 10 | // vars taken from os.UserHomeDir 11 | env := "HOME" 12 | switch runtime.GOOS { 13 | case "windows": 14 | env = "USERPROFILE" 15 | case "plan9": 16 | env = "home" 17 | } 18 | return os.Setenv(env, dir) 19 | } 20 | 21 | func MustGetUserHomeDir() string { 22 | dir, err := os.UserHomeDir() 23 | if err != nil { 24 | panic(err) 25 | } 26 | return dir 27 | } 28 | 29 | func MustGetFjiraHomeDir() string { 30 | dir := MustGetUserHomeDir() 31 | userHomeDir := fmt.Sprintf("%s/.fjira", dir) 32 | xdg := os.Getenv("XDG_CONFIG_HOME") 33 | xdgDir := fmt.Sprintf("%s/fjira", xdg) 34 | if xdg != "" && dirExist(xdgDir) { 35 | return xdgDir 36 | } 37 | if xdg != "" && !dirExist(userHomeDir) { 38 | return xdgDir 39 | } 40 | return userHomeDir 41 | } 42 | 43 | func dirExist(path string) bool { 44 | if stat, err := os.Stat(path); err == nil && stat.IsDir() { 45 | return true 46 | } 47 | return false 48 | } 49 | -------------------------------------------------------------------------------- /internal/app/text_box_test.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "bytes" 5 | "github.com/gdamore/tcell/v2" 6 | "github.com/stretchr/testify/assert" 7 | "strings" 8 | "testing" 9 | ) 10 | 11 | func TestNewTextBox(t *testing.T) { 12 | screen := tcell.NewSimulationScreen("utf-8") 13 | _ = screen.Init() //nolint:errcheck 14 | defer screen.Fini() 15 | 16 | tests := []struct { 17 | name string 18 | }{ 19 | {"should render text box"}, 20 | } 21 | for _, tt := range tests { 22 | t.Run(tt.name, func(t *testing.T) { 23 | // given 24 | box := NewTextBox(0, 0, tcell.StyleDefault, tcell.StyleDefault, "abc") 25 | box.SetX(0) 26 | box.SetY(0) 27 | 28 | // when 29 | box.Draw(screen) 30 | var buffer bytes.Buffer 31 | contents, x, y := screen.GetContents() 32 | screen.Show() 33 | for i := 0; i < x*y; i++ { 34 | if string(contents[i].Bytes) != "" { 35 | buffer.Write(contents[i].Bytes) 36 | } 37 | } 38 | result := strings.TrimSpace(buffer.String()) 39 | 40 | // then 41 | assert.Contains(t, result, "└─────┘") 42 | }) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /internal/app/action_bar_test.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "github.com/stretchr/testify/assert" 5 | "testing" 6 | ) 7 | 8 | func TestActionBar_AddItem(t *testing.T) { 9 | tests := []struct { 10 | name string 11 | }{ 12 | {"should create action bar item"}, 13 | } 14 | for _, tt := range tests { 15 | t.Run(tt.name, func(t *testing.T) { 16 | // given 17 | ab := NewActionBar(0, 1) 18 | ab.Update() 19 | 20 | // when 21 | ab.AddTextItem("id", "test") 22 | 23 | // then 24 | assert.Equal(t, 1, len(ab.items)) 25 | 26 | // and when 27 | ab.RemoveItemAtIndex(0) 28 | 29 | // then 30 | assert.Equal(t, 0, len(ab.items)) 31 | 32 | // when 33 | ab.AddTextItem("id1", "test") 34 | ab.AddTextItem("id2", "test") 35 | ab.AddTextItem("id3", "test") 36 | ab.AddTextItem("id3", "test") 37 | 38 | // and when 39 | ab.TrimItemsTo(2) 40 | 41 | // then 42 | assert.Equal(t, 2, len(ab.items)) 43 | 44 | // and when 45 | ab.RemoveItem(ab.items[0].Id) 46 | 47 | // then 48 | assert.Equal(t, 1, len(ab.items)) 49 | }) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /.github/workflows/golangci-lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | on: 3 | push: 4 | tags: 5 | branches: 6 | - master 7 | pull_request: 8 | 9 | jobs: 10 | golangci: 11 | strategy: 12 | matrix: 13 | go: [ 1.21 ] 14 | os: [ macos-latest ] 15 | name: lint 16 | runs-on: ${{ matrix.os }} 17 | steps: 18 | - uses: actions/setup-go@v3 19 | with: 20 | go-version: ${{ matrix.go }} 21 | - uses: actions/checkout@v3 22 | - name: golangci-lint 23 | uses: golangci/golangci-lint-action@v3 24 | with: 25 | # Required: the version of golangci-lint is required and must be specified without patch version: we always use the latest patch version. 26 | version: v1.55 27 | # Optional: working directory, useful for monorepos 28 | # working-directory: somedir 29 | 30 | # Optional: golangci-lint command line arguments. 31 | # args: --issues-exit-code=0 32 | 33 | # Optional: show only new issues if it's a pull request. The default value is `false`. 34 | # only-new-issues: true 35 | -------------------------------------------------------------------------------- /internal/app/action_bar_item.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "fmt" 5 | "github.com/gdamore/tcell/v2" 6 | ) 7 | 8 | type ActionBarItem struct { 9 | Id int 10 | Text1 string 11 | Text2 string 12 | Text1Style tcell.Style 13 | Text2Style tcell.Style 14 | TriggerRune rune 15 | TriggerKey tcell.Key 16 | text string 17 | x int 18 | y int 19 | } 20 | 21 | func NewActionBarItem(id int, text string, triggerRune rune, triggerKey tcell.Key) *ActionBarItem { 22 | item := &ActionBarItem{ 23 | Id: id, 24 | TriggerRune: triggerRune, 25 | TriggerKey: triggerKey, 26 | } 27 | item.Text1 = text 28 | return item 29 | } 30 | 31 | func ActionBarLabel(str string) string { 32 | if str == "" { 33 | return MessageLabelNone 34 | } 35 | return str 36 | } 37 | 38 | func (b *ActionBarItem) ChangeText(text1 string, text2 string) { 39 | b.Text2 = text2 40 | b.Text1 = text1 41 | b.text = fmt.Sprintf("%s%s", b.Text1, b.Text2) 42 | } 43 | 44 | func (b *ActionBarItem) ChangeText2(text2 string) { 45 | b.Text2 = text2 46 | b.text = fmt.Sprintf("%s%s", b.Text1, b.Text2) 47 | } 48 | -------------------------------------------------------------------------------- /internal/app/spinner.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import "github.com/gdamore/tcell/v2" 4 | 5 | type SpinnerTCell struct { 6 | spinner []string 7 | text string 8 | textStyle tcell.Style 9 | styles []tcell.Style 10 | spinnerIndex *int 11 | } 12 | 13 | func NewSimpleSpinner() *SpinnerTCell { 14 | return &SpinnerTCell{ 15 | spinner: []string{".....", "....", ".."}, 16 | styles: []tcell.Style{ 17 | DefaultStyle(), DefaultStyle().Foreground(Color("spinner.accent")).Bold(true), DefaultStyle(), 18 | }, 19 | textStyle: DefaultStyle().Italic(true).Blink(true), 20 | spinnerIndex: new(int), 21 | } 22 | } 23 | 24 | func (t *SpinnerTCell) Draw(screen tcell.Screen) { 25 | screenX, screenY := screen.Size() 26 | index := (*t.spinnerIndex + 1) % len(t.spinner) 27 | *t.spinnerIndex = index 28 | row := screenY - 1 29 | col := screenX - len(t.spinner[index]) - 1 30 | if t.text != "" { 31 | col -= len(t.text) + 1 32 | DrawText(screen, screenX-1-len(t.text), screenY-1, t.textStyle, t.text) 33 | } 34 | for _, r := range t.spinner[index] { 35 | screen.SetContent(col, row, r, nil, t.styles[index]) 36 | col++ 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /internal/app/text_test.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "bytes" 5 | "github.com/gdamore/tcell/v2" 6 | "github.com/stretchr/testify/assert" 7 | "strings" 8 | "testing" 9 | ) 10 | 11 | func TestNewText(t *testing.T) { 12 | screen := tcell.NewSimulationScreen("utf-8") 13 | _ = screen.Init() //nolint:errcheck 14 | defer screen.Fini() 15 | 16 | type args struct { 17 | text string 18 | } 19 | tests := []struct { 20 | name string 21 | args args 22 | }{ 23 | {"should render text", args{text: "abcde"}}, 24 | } 25 | for _, tt := range tests { 26 | t.Run(tt.name, func(t *testing.T) { 27 | // given 28 | text := NewText(0, 0, tcell.StyleDefault, tt.args.text) 29 | text.ChangeText(tt.args.text) 30 | 31 | // when 32 | text.Draw(screen) 33 | var buffer bytes.Buffer 34 | contents, x, y := screen.GetContents() 35 | screen.Show() 36 | for i := 0; i < x*y; i++ { 37 | if string(contents[i].Bytes) != "" { 38 | buffer.Write(contents[i].Bytes) 39 | } 40 | } 41 | result := strings.TrimSpace(buffer.String()) 42 | 43 | // then 44 | assert.Contains(t, result, tt.args.text) 45 | }) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/mk-5/fjira 2 | 3 | go 1.21 4 | 5 | require ( 6 | github.com/bep/debounce v1.2.1 7 | github.com/fatih/color v1.13.0 8 | github.com/gdamore/tcell/v2 v2.7.0 9 | github.com/google/go-querystring v1.1.0 10 | github.com/sahilm/fuzzy v0.1.0 11 | github.com/spf13/cobra v1.7.0 12 | github.com/stretchr/testify v1.7.1 13 | gopkg.in/yaml.v3 v3.0.1 14 | ) 15 | 16 | require ( 17 | github.com/davecgh/go-spew v1.1.1 // indirect 18 | github.com/gdamore/encoding v1.0.0 // indirect 19 | github.com/google/go-cmp v0.5.7 // indirect 20 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 21 | github.com/kylelemons/godebug v1.1.0 // indirect 22 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 23 | github.com/mattn/go-colorable v0.1.9 // indirect 24 | github.com/mattn/go-isatty v0.0.14 // indirect 25 | github.com/mattn/go-runewidth v0.0.15 // indirect 26 | github.com/pmezard/go-difflib v1.0.0 // indirect 27 | github.com/rivo/uniseg v0.4.3 // indirect 28 | github.com/spf13/pflag v1.0.5 // indirect 29 | golang.org/x/sys v0.15.0 // indirect 30 | golang.org/x/term v0.15.0 // indirect 31 | golang.org/x/text v0.14.0 // indirect 32 | ) 33 | -------------------------------------------------------------------------------- /assets/colors.yml: -------------------------------------------------------------------------------- 1 | default: 2 | background: "#161616" 3 | foreground: "#c7c7c7" 4 | foreground2: "#FFFFFF" 5 | 6 | finder: 7 | cursor: "#8B0000" 8 | title: "#E9CE58" 9 | match: "#90EE90" 10 | highlight: 11 | background: "#3A3A3A" 12 | foreground: "#FFFFFF" 13 | match: "#E0FFFF" 14 | 15 | navigation: 16 | top: 17 | background: "#5F875f" 18 | foreground1: "#FFFFFF" 19 | foreground2: "#151515" 20 | bottom: 21 | background: "#5F87AF" 22 | foreground1: "#FFFFFF" 23 | foreground2: "#151515" 24 | 25 | details: 26 | foreground: "#696969" 27 | 28 | boards: 29 | title: 30 | foreground: "#ecce58" 31 | headers: 32 | background: "#5F875f" 33 | foreground: "#FFFFFF" 34 | column: 35 | background: "#232323" 36 | foreground: "#ffffff" 37 | highlight: 38 | background: "#484848" 39 | foreground: "#ffffff" 40 | selection: 41 | background: "#8B0000" 42 | foreground: "#ffffff" 43 | 44 | spinner: 45 | accent: "#FF0000" 46 | 47 | alerts: 48 | success: 49 | background: "#F5F5F5" 50 | foreground: "#006400" 51 | error: 52 | background: "#F5F5F5" 53 | foreground: "#8B0000" 54 | -------------------------------------------------------------------------------- /internal/jira/jira_project_statuses.go: -------------------------------------------------------------------------------- 1 | package jira 2 | 3 | import ( 4 | "encoding/json" 5 | "github.com/mk-5/fjira/internal/app" 6 | "strings" 7 | ) 8 | 9 | // 10 | // https://docs.atlassian.com/software/jira/docs/api/REST/8.5.1/#api/2/project-getAllStatuses 11 | // 12 | 13 | type statusesResponse struct { 14 | Statuses []IssueStatus `json:"statuses"` 15 | } 16 | 17 | const ( 18 | GetProjectStatuses = "/rest/api/2/project/{project}/statuses" 19 | ) 20 | 21 | func (a *httpApi) FindProjectStatuses(projectId string) ([]IssueStatus, error) { 22 | responseBody, _ := a.jiraRequest("GET", strings.Replace(GetProjectStatuses, "{project}", projectId, 1), &nilParams{}, nil) 23 | var sResponse []statusesResponse 24 | distinct := make(map[string]bool) 25 | if err := json.Unmarshal(responseBody, &sResponse); err != nil { 26 | app.Error(err.Error()) 27 | return nil, SearchDeserializeErr 28 | } 29 | var statuses = make([]IssueStatus, 0, 100) 30 | for _, row := range sResponse { 31 | for _, status := range row.Statuses { 32 | if distinct[status.Name] { 33 | continue 34 | } 35 | statuses = append(statuses, status) 36 | distinct[status.Name] = true 37 | } 38 | } 39 | return statuses, nil 40 | } 41 | -------------------------------------------------------------------------------- /internal/app/goto_test.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "github.com/stretchr/testify/assert" 5 | "testing" 6 | ) 7 | 8 | func TestRegisterGoto(t *testing.T) { 9 | tests := []struct { 10 | name string 11 | }{ 12 | {"should register, and run valid goTo function"}, 13 | } 14 | for _, tt := range tests { 15 | t.Run(tt.name, func(t *testing.T) { 16 | // given 17 | done := make(chan interface{}) 18 | RegisterGoto("test-screen", func(args ...interface{}) { 19 | done <- args[0] 20 | }) 21 | RegisterGoto("test-screen2", func(args ...interface{}) { 22 | }) 23 | 24 | // when 25 | go GoTo("test-screen", "test-arg") 26 | 27 | // then 28 | arg1 := <-done 29 | assert.Equal(t, "test-arg", arg1) 30 | assert.Equal(t, "test-screen", CurrentScreenName()) 31 | 32 | // and when: move forward 33 | GoTo("test-screen2") 34 | 35 | // then 36 | assert.Equal(t, "test-screen2", CurrentScreenName()) 37 | assert.Equal(t, "test-screen", PreviousScreenName()) 38 | 39 | // and when: go back 40 | go GoBack() 41 | 42 | // then 43 | <-done 44 | assert.Equal(t, "test-screen", CurrentScreenName()) 45 | assert.Equal(t, "test-screen2", PreviousScreenName()) 46 | }) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /internal/jira/jira_filters.go: -------------------------------------------------------------------------------- 1 | package jira 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | ) 7 | 8 | type Filter struct { 9 | Id string `json:"id"` 10 | Name string `json:"name"` 11 | JQL string `json:"jql"` 12 | Favourite bool `json:"favourite"` 13 | } 14 | 15 | const ( 16 | FilterUrl = "/rest/api/2/filter/%s" 17 | MyFilterUrl = "/rest/api/2/filter/my" 18 | MyFilterUrlJiraServer = "/rest/api/2/filter/favourite" 19 | ) 20 | 21 | func (api *httpApi) GetFilter(filterId string) (*Filter, error) { 22 | resultBytes, err := api.jiraRequest("GET", fmt.Sprintf(FilterUrl, filterId), &nilParams{}, nil) 23 | if err != nil { 24 | return nil, err 25 | } 26 | var result Filter 27 | err = json.Unmarshal(resultBytes, &result) 28 | if err != nil { 29 | return nil, err 30 | } 31 | return &result, nil 32 | } 33 | 34 | func (api *httpApi) GetMyFilters() ([]Filter, error) { 35 | url := MyFilterUrl 36 | if api.IsJiraServer() { 37 | url = MyFilterUrlJiraServer 38 | } 39 | resultBytes, err := api.jiraRequest("GET", url, &nilParams{}, nil) 40 | if err != nil { 41 | return nil, err 42 | } 43 | var result []Filter 44 | err = json.Unmarshal(resultBytes, &result) 45 | if err != nil { 46 | return nil, err 47 | } 48 | return result, nil 49 | } 50 | -------------------------------------------------------------------------------- /internal/app/goto.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | type goToHistory struct { 4 | screenName string 5 | args []interface{} 6 | } 7 | 8 | var ( 9 | gotoRegistry = map[string]func(args ...interface{}){} 10 | currentGoTo = &goToHistory{} 11 | previousGoTo = &goToHistory{} 12 | ) 13 | 14 | func RegisterGoto(name string, f func(args ...interface{})) { 15 | gotoRegistry[name] = f 16 | } 17 | 18 | // GoTo it's not a perfect solution ... but it's the only one 19 | // that works, and not lead into cycled-import errors. 20 | // For example, you can go from projects view into issues view, and from issues view into projects view. 21 | // Both views are in different packages therefor it leas to cyclic-import 22 | func GoTo(name string, args ...interface{}) { 23 | defer GetApp().PanicRecover() 24 | if f, ok := gotoRegistry[name]; ok { 25 | f(args...) 26 | previousGoTo.screenName = currentGoTo.screenName 27 | previousGoTo.args = currentGoTo.args 28 | currentGoTo.screenName = name 29 | currentGoTo.args = args 30 | } 31 | } 32 | 33 | func CurrentScreenName() string { 34 | return currentGoTo.screenName 35 | } 36 | 37 | func PreviousScreenName() string { 38 | return previousGoTo.screenName 39 | } 40 | 41 | func GoBack() { 42 | if previousGoTo.screenName != "" { 43 | GoTo(previousGoTo.screenName, previousGoTo.args) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /internal/jira/jira_request.go: -------------------------------------------------------------------------------- 1 | package jira 2 | 3 | import ( 4 | "fmt" 5 | "github.com/google/go-querystring/query" 6 | "io" 7 | "net/http" 8 | "net/url" 9 | "path" 10 | ) 11 | 12 | func (api *httpApi) jiraRequest(method string, restPath string, queryParams interface{}, reqBody io.Reader) ([]byte, error) { 13 | u, err := api.jiraRequestUrl(restPath, queryParams) 14 | if err != nil { 15 | return nil, err 16 | } 17 | req, err := http.NewRequest(method, u, reqBody) 18 | req.Header.Add("Accept", "application/json") 19 | req.Header.Add("Content-Type", "application/json") 20 | if err != nil { 21 | return nil, err 22 | } 23 | response, err := api.client.Do(req) 24 | if err != nil { 25 | return nil, err 26 | } 27 | if response.StatusCode >= 400 { 28 | return nil, fmt.Errorf("Jira error, status: %s - request: %s", response.Status, req.RequestURI) 29 | } 30 | defer response.Body.Close() 31 | body, _ := io.ReadAll(response.Body) 32 | return body, nil 33 | } 34 | 35 | func (api *httpApi) jiraRequestUrl(restPath string, queryParams interface{}) (string, error) { 36 | queryParamsValues, err := query.Values(queryParams) 37 | if err != nil { 38 | return "", err 39 | } 40 | u := api.restUrl.ResolveReference(&url.URL{Path: path.Join(api.restUrl.Path, restPath), RawQuery: queryParamsValues.Encode()}) 41 | return u.String(), err 42 | } 43 | -------------------------------------------------------------------------------- /cmd/fjira-cli/commands/workspace.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "github.com/mk-5/fjira/internal/fjira" 5 | "github.com/mk-5/fjira/internal/workspaces" 6 | "github.com/spf13/cobra" 7 | "log" 8 | "os" 9 | ) 10 | 11 | func GetWorkspaceCmd() *cobra.Command { 12 | cmd := &cobra.Command{ 13 | Use: "workspace", 14 | Short: "Switch to a different workspace", 15 | Run: func(cmd *cobra.Command, args []string) { 16 | edit, _ := cmd.Flags().GetString("edit") 17 | n, _ := cmd.Flags().GetString("new") 18 | var s *workspaces.WorkspaceSettings 19 | var err error 20 | if edit != "" { 21 | s, err = fjira.EditWorkspaceAndReadSettings(os.Stdin, edit) 22 | if err != nil { 23 | log.Println(err) 24 | log.Fatalln(fjira.InstallFailedErr.Error()) 25 | } 26 | } else if n != "" { 27 | s, err = fjira.Install(n) 28 | if err != nil { 29 | log.Println(err) 30 | log.Fatalln(fjira.InstallFailedErr.Error()) 31 | } 32 | } else { 33 | s = cmd.Context().Value(CtxWorkspaceSettings).(*workspaces.WorkspaceSettings) 34 | } 35 | f := fjira.CreateNewFjira(s) 36 | defer f.Close() 37 | f.Run(&fjira.CliArgs{ 38 | WorkspaceSwitch: true, 39 | }) 40 | }, 41 | } 42 | cmd.Flags().String("edit", "", "Edit workspace") 43 | cmd.Flags().String("new", "", "Create a new workspace") 44 | return cmd 45 | } 46 | -------------------------------------------------------------------------------- /internal/issues/jql_builder.go: -------------------------------------------------------------------------------- 1 | package issues 2 | 3 | import ( 4 | "fmt" 5 | "github.com/mk-5/fjira/internal/jira" 6 | "github.com/mk-5/fjira/internal/ui" 7 | "strings" 8 | ) 9 | 10 | func BuildSearchIssuesJql(project *jira.Project, query string, status *jira.IssueStatus, user *jira.User, label string) string { 11 | jql := "" 12 | if project != nil && project.Id != ui.MessageAll { 13 | jql = jql + fmt.Sprintf("project=%s", project.Id) 14 | } 15 | orderBy := "ORDER BY status" 16 | query = strings.TrimSpace(query) 17 | if query != "" { 18 | jql = jql + fmt.Sprintf(" AND summary~\"%s*\"", query) 19 | } 20 | if status != nil && status.Name != ui.MessageAll { 21 | jql = jql + fmt.Sprintf(" AND status=%s", status.Id) 22 | } 23 | if user != nil && user.DisplayName != ui.MessageAll { 24 | userId := user.AccountId 25 | if userId == "" { 26 | userId = user.Name 27 | } 28 | jql = jql + fmt.Sprintf(" AND assignee=%s", userId) 29 | } 30 | // TODO - would be safer to check the index of inserted all message, instead of checking it like this / same for all All checks 31 | if label != "" && label != ui.MessageAll { 32 | jql = jql + fmt.Sprintf(" AND labels=%s", label) 33 | } 34 | if query != "" && issueRegExp.MatchString(query) { 35 | jql = jql + fmt.Sprintf(" OR issuekey=\"%s\"", query) 36 | } 37 | return fmt.Sprintf("%s %s", strings.TrimLeft(jql, " AND"), orderBy) 38 | } 39 | -------------------------------------------------------------------------------- /internal/jira/transport.go: -------------------------------------------------------------------------------- 1 | package jira 2 | 3 | import ( 4 | "crypto/tls" 5 | "crypto/x509" 6 | "fmt" 7 | "github.com/mk-5/fjira/internal/app" 8 | "net" 9 | "net/http" 10 | "os" 11 | "time" 12 | ) 13 | 14 | var ( 15 | defaultHttpTransport = createHttpTransport() 16 | ) 17 | 18 | func createHttpTransport() *http.Transport { 19 | t := &http.Transport{ 20 | Proxy: http.ProxyFromEnvironment, 21 | DialContext: (&net.Dialer{ 22 | Timeout: 30 * time.Second, 23 | KeepAlive: 30 * time.Second, 24 | }).DialContext, 25 | ForceAttemptHTTP2: true, 26 | MaxIdleConns: 100, 27 | IdleConnTimeout: 90 * time.Second, 28 | TLSHandshakeTimeout: 10 * time.Second, 29 | ExpectContinueTimeout: 1 * time.Second, 30 | } 31 | if f := os.Getenv("SSL_CERT_FILE"); f != "" { 32 | t.TLSClientConfig = &tls.Config{ 33 | MinVersion: tls.VersionTLS12, 34 | } 35 | data, err := os.ReadFile(f) 36 | if err != nil { 37 | app.Error(fmt.Sprintf("Cannot read file for SSL_CERT_FILE. %s", err.Error())) 38 | app.GetApp().Quit() 39 | return t 40 | } 41 | rootCAs := systemCertPool() 42 | rootCAs.AppendCertsFromPEM(data) 43 | t.TLSClientConfig.RootCAs = rootCAs 44 | } 45 | return t 46 | } 47 | 48 | func systemCertPool() *x509.CertPool { 49 | pool, err := x509.SystemCertPool() 50 | if err != nil { 51 | return x509.NewCertPool() 52 | } 53 | return pool 54 | } 55 | -------------------------------------------------------------------------------- /internal/boards/goto.go: -------------------------------------------------------------------------------- 1 | package boards 2 | 3 | import ( 4 | "github.com/mk-5/fjira/internal/app" 5 | "github.com/mk-5/fjira/internal/jira" 6 | ) 7 | 8 | func RegisterGoTo() { 9 | app.RegisterGoto("boards", func(args ...interface{}) { 10 | project := args[0].(*jira.Project) 11 | board := args[1].(*jira.BoardItem) 12 | var goBackFn func() 13 | if fn, ok := args[2].(func()); ok { 14 | goBackFn = fn 15 | } 16 | api := args[3].(jira.Api) 17 | 18 | defer app.GetApp().PanicRecover() 19 | app.GetApp().Loading(true) 20 | boardConfig, err := api.GetBoardConfiguration(board.Id) 21 | if err != nil { 22 | app.GetApp().Loading(false) 23 | app.Error(err.Error()) 24 | return 25 | } 26 | filter, err := api.GetFilter(boardConfig.Filter.Id) 27 | if err != nil { 28 | app.GetApp().Loading(false) 29 | app.Error(err.Error()) 30 | return 31 | } 32 | var sprints []jira.SprintItem 33 | if boardConfig.Type == "scrum" { 34 | sprints, err = api.GetBoardSprints(boardConfig.Id) 35 | if err != nil { 36 | app.GetApp().Loading(false) 37 | app.Error(err.Error()) 38 | return 39 | } 40 | } 41 | app.GetApp().Loading(false) 42 | boardView := NewBoardView(project, boardConfig, filter.JQL, api).(*boardView) 43 | if sprints != nil { 44 | boardView.SetSprints(sprints) 45 | } 46 | boardView.SetGoBackFn(goBackFn) 47 | app.GetApp().SetView(boardView) 48 | }) 49 | } 50 | -------------------------------------------------------------------------------- /internal/jira/jira_assignee.go: -------------------------------------------------------------------------------- 1 | package jira 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "strings" 8 | ) 9 | 10 | type cloudAssigneeRequestBody struct { 11 | AccountId string `json:"accountId"` 12 | } 13 | 14 | type onPremiseAssigneeRequestBody struct { 15 | Fields struct { 16 | Assignee struct { 17 | Name string `json:"name"` 18 | } `json:"assignee"` 19 | } `json:"fields"` 20 | } 21 | 22 | const ( 23 | DoAssigneePathCloud = "/rest/api/2/issue/%s/assignee" 24 | DoAssigneePathOnPremise = "/rest/api/2/issue/%s" 25 | ) 26 | 27 | var ( 28 | CannotPerformAssignmentErr = errors.New("invalid assignee data. Cannot perform do-assignment request") 29 | ) 30 | 31 | func (api *httpApi) DoAssignee(issueId string, user *User) error { 32 | var url string 33 | var body interface{} 34 | if user.AccountId != "" { 35 | url = fmt.Sprintf(DoAssigneePathCloud, issueId) 36 | body = &cloudAssigneeRequestBody{AccountId: user.AccountId} 37 | } else if user.Name != "" { 38 | url = fmt.Sprintf(DoAssigneePathOnPremise, issueId) 39 | body = &onPremiseAssigneeRequestBody{} 40 | (body.(*onPremiseAssigneeRequestBody)).Fields.Assignee.Name = user.Name 41 | } else { 42 | return CannotPerformAssignmentErr 43 | } 44 | jsonBody, _ := json.Marshal(body) 45 | _, err := api.jiraRequest("PUT", url, nil, strings.NewReader(string(jsonBody))) 46 | if err != nil { 47 | return err 48 | } 49 | return nil 50 | } 51 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | on: 3 | push: 4 | tags: 5 | branches: 6 | - master 7 | pull_request: 8 | 9 | jobs: 10 | test: 11 | strategy: 12 | matrix: 13 | go-version: [ 1.21.x ] 14 | os: [ ubuntu-latest, macos-latest ] 15 | runs-on: ${{ matrix.os }} 16 | steps: 17 | - uses: actions/setup-go@v3 18 | with: 19 | go-version: ${{ matrix.go-version }} 20 | - uses: actions/checkout@v3 21 | - run: | 22 | export XDG_CONFIG_HOME="" 23 | go test -coverprofile=coverage.out -coverpkg=./... -covermode=count ./internal/... 24 | COVERAGE=$(go tool cover -func=coverage.out | grep total | awk '{print substr($3, 1, length($3)-1)}') 25 | echo "Total coverage: $COVERAGE" 26 | - name: Upload coverage to Codecov 27 | uses: codecov/codecov-action@v3 28 | if: matrix.os == 'ubuntu-latest' 29 | - name: Run BATS tests 30 | uses: mig4/setup-bats@v1 31 | with: 32 | bats-version: 1.9.0 33 | - run: | 34 | make build 35 | bats bats/test.bats 36 | test_windows: 37 | strategy: 38 | matrix: 39 | go-version: [ 1.21.x ] 40 | os: [ windows-latest ] 41 | runs-on: ${{ matrix.os }} 42 | steps: 43 | - uses: actions/setup-go@v3 44 | with: 45 | go-version: ${{ matrix.go-version }} 46 | - uses: actions/checkout@v3 47 | - run: | 48 | go test ./internal/... 49 | -------------------------------------------------------------------------------- /internal/jira/jira_assignee_test.go: -------------------------------------------------------------------------------- 1 | package jira 2 | 3 | import ( 4 | "github.com/stretchr/testify/assert" 5 | "net/http" 6 | "testing" 7 | ) 8 | 9 | func Test_httpJiraApi_DoAssigneeCloud(t *testing.T) { 10 | tests := []struct { 11 | name string 12 | }{ 13 | {"should do CLOUD assignment without error"}, 14 | } 15 | for _, tt := range tests { 16 | t.Run(tt.name, func(t *testing.T) { 17 | // given 18 | var url string 19 | api := NewJiraApiMock(func(w http.ResponseWriter, r *http.Request) { 20 | w.WriteHeader(200) 21 | w.Write([]byte(``)) //nolint:errcheck 22 | url = r.URL.Path 23 | }) 24 | 25 | // when 26 | err := api.DoAssignee("ISS123", &User{AccountId: "acc123"}) 27 | 28 | // then 29 | assert.Nil(t, err) 30 | assert.Equal(t, "/rest/api/2/issue/ISS123/assignee", url) 31 | }) 32 | } 33 | } 34 | 35 | func Test_httpJiraApi_DoAssigneeOnPremise(t *testing.T) { 36 | tests := []struct { 37 | name string 38 | }{ 39 | {"should do SERVER assignment without error"}, 40 | } 41 | for _, tt := range tests { 42 | t.Run(tt.name, func(t *testing.T) { 43 | // given 44 | var url string 45 | api := NewJiraApiMock(func(w http.ResponseWriter, r *http.Request) { 46 | w.WriteHeader(200) 47 | w.Write([]byte(``)) //nolint:errcheck 48 | url = r.URL.Path 49 | }) 50 | 51 | // when 52 | err := api.DoAssignee("ISS123", &User{Name: "username"}) 53 | 54 | // then 55 | assert.Nil(t, err) 56 | assert.Equal(t, "/rest/api/2/issue/ISS123", url) 57 | }) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /internal/app/flash_test.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "bytes" 5 | "github.com/gdamore/tcell/v2" 6 | "github.com/stretchr/testify/assert" 7 | "strings" 8 | "testing" 9 | ) 10 | 11 | func TestFlush(t *testing.T) { 12 | screen := tcell.NewSimulationScreen("utf-8") 13 | _ = screen.Init() //nolint:errcheck 14 | defer screen.Fini() 15 | app := CreateNewAppWithScreen(screen) 16 | 17 | type args struct { 18 | message string 19 | } 20 | 21 | tests := []struct { 22 | name string 23 | args args 24 | }{ 25 | {"should render flush messages", args{message: "Flush message!"}}, 26 | } 27 | for _, tt := range tests { 28 | t.Run(tt.name, func(t *testing.T) { 29 | // when 30 | Success(tt.args.message) 31 | app.flash[0].Draw(screen) 32 | 33 | var buffer bytes.Buffer 34 | contents, x, y := screen.GetContents() 35 | screen.Show() 36 | for i := 0; i < x*y; i++ { 37 | if string(contents[i].Bytes) != "" { 38 | buffer.Write(contents[i].Bytes) 39 | } 40 | } 41 | result := strings.TrimSpace(buffer.String()) 42 | 43 | // then 44 | assert.Contains(t, result, tt.args.message) 45 | 46 | // and when 47 | Error(tt.args.message) 48 | app.flash[0].Draw(screen) 49 | 50 | buffer.Reset() 51 | contents, x, y = screen.GetContents() 52 | screen.Show() 53 | for i := 0; i < x*y; i++ { 54 | if string(contents[i].Bytes) != "" { 55 | buffer.Write(contents[i].Bytes) 56 | } 57 | } 58 | result = strings.TrimSpace(buffer.String()) 59 | 60 | // then 61 | assert.Contains(t, result, tt.args.message) 62 | }) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /.github/workflows/check-commit.yml: -------------------------------------------------------------------------------- 1 | name: Check Commit 2 | on: 3 | push: 4 | tags: 5 | branches: 6 | - master 7 | pull_request: 8 | 9 | jobs: 10 | build: 11 | name: Check Commit 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Verify commit message [push] 15 | id: verify_commit_message 16 | run: | 17 | if [[ ! "${{ github.event.head_commit.message }}" =~ ^(feat\(*[a-z0-9#]*\)*:)|(fix\(*[a-z0-9#]*\)*:)|(docs\(*[a-z0-9#]*\)*:)|(build\(*[a-z0-9#]*\)*:)|(refactor\(*[a-z0-9#]*\)*:)|(chore\(*[a-z0-9#]*\)*:)|(misc\(*[a-z0-9#]*\)*:).* ]]; then 18 | echo "Invalid commit message: " 19 | echo "${{ github.event.head_commit.message }}" 20 | exit 1 21 | fi 22 | if: ${{ github.event_name == 'push' }} 23 | - name: Verify commit message [pr] - get repo 24 | uses: actions/checkout@v3 25 | with: 26 | ref: ${{ github.event.pull_request.head.sha }} 27 | if: ${{ github.event_name == 'pull_request' }} 28 | - name: Verify commit message [pr] - verify 29 | id: get_head_commit_message 30 | run: | 31 | export commit=$(git show -s --format=%s) 32 | if [[ ! "$commit" =~ ^(feat\(*[a-z0-9#]*\)*:)|(fix\(*[a-z0-9#]*\)*:)|(docs\(*[a-z0-9#]*\)*:)|(build\(*[a-z0-9#]*\)*:)|(refactor\(*[a-z0-9#]*\)*:)|(chore\(*[a-z0-9#]*\)*:)|(misc\(*[a-z0-9#]*\)*:).* ]]; then 33 | echo "Invalid commit message: " 34 | echo "$commit" 35 | exit 1 36 | fi 37 | if: ${{ github.event_name == 'pull_request' }} 38 | -------------------------------------------------------------------------------- /internal/jira/jira_issue.go: -------------------------------------------------------------------------------- 1 | package jira 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | ) 7 | 8 | type IssueType struct { 9 | Name string `json:"name"` 10 | } 11 | 12 | type Issue struct { 13 | Key string `json:"key"` 14 | Fields IssueFields `json:"Fields"` 15 | Id string `json:"id"` 16 | } 17 | 18 | type IssueFields struct { 19 | Summary string `json:"summary"` 20 | Project Project `json:"project"` 21 | Description string `json:"description,omitempty"` 22 | Reporter struct { 23 | AccountId string `json:"accountId"` 24 | DisplayName string `json:"displayName"` 25 | } `json:"reporter"` 26 | Assignee struct { 27 | AccountId string `json:"accountId"` 28 | DisplayName string `json:"displayName"` 29 | } `json:"assignee"` 30 | Type struct { 31 | Name string `json:"name"` 32 | } `json:"issuetype"` 33 | Status Status 34 | Comment struct { 35 | Comments []Comment `json:"comments"` 36 | MaxResults int32 `json:"maxResults"` 37 | Total int32 `json:"total"` 38 | StartAt int32 `json:"startAt"` 39 | } `json:"comment"` 40 | Labels []string `json:"labels"` 41 | } 42 | 43 | const ( 44 | GetJiraIssuePath = "/rest/api/2/issue/%s" 45 | ) 46 | 47 | func (api *httpApi) GetIssueDetailed(id string) (*Issue, error) { 48 | body, err := api.jiraRequest("GET", fmt.Sprintf(GetJiraIssuePath, id), &nilParams{}, nil) 49 | if err != nil { 50 | return nil, err 51 | } 52 | var jiraIssue Issue 53 | if err := json.Unmarshal(body, &jiraIssue); err != nil { 54 | return nil, SearchDeserializeErr 55 | } 56 | return &jiraIssue, nil 57 | } 58 | -------------------------------------------------------------------------------- /internal/app/confirmation.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "github.com/gdamore/tcell/v2" 5 | ) 6 | 7 | type Confirmation struct { 8 | Complete chan bool 9 | message string 10 | screenX int 11 | screenY int 12 | style tcell.Style 13 | questionMarkStyle tcell.Style 14 | } 15 | 16 | const ( 17 | Yes = 'y' 18 | No = 'n' 19 | QuestionMark = "? " 20 | ) 21 | 22 | func Confirm(app *App, message string) bool { 23 | confirmation := newConfirmation(message) 24 | app.AddDrawable(confirmation) 25 | app.AddSystem(confirmation) 26 | if yesNo := <-confirmation.Complete; true { 27 | return yesNo 28 | } 29 | return false 30 | } 31 | 32 | func newConfirmation(message string) *Confirmation { 33 | return &Confirmation{ 34 | Complete: make(chan bool), 35 | message: message, 36 | style: DefaultStyle(), 37 | questionMarkStyle: DefaultStyle().Bold(true).Foreground(Color("finder.title")), 38 | } 39 | } 40 | 41 | func (c *Confirmation) Draw(screen tcell.Screen) { 42 | DrawText(screen, 0, c.screenY-2, c.questionMarkStyle, QuestionMark) 43 | DrawText(screen, 2, c.screenY-2, c.style, c.message) 44 | } 45 | 46 | func (c *Confirmation) Resize(screenX, screenY int) { 47 | c.screenX = screenX 48 | c.screenY = screenY 49 | } 50 | 51 | func (c *Confirmation) Update() { 52 | // do nothing 53 | } 54 | 55 | func (c *Confirmation) HandleKeyEvent(ev *tcell.EventKey) { 56 | if ev.Key() == tcell.KeyEscape { 57 | c.Complete <- false 58 | return 59 | } 60 | switch ev.Rune() { 61 | case Yes: 62 | c.Complete <- true 63 | case No: 64 | c.Complete <- false 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /internal/jira/jira_projects.go: -------------------------------------------------------------------------------- 1 | package jira 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "net/url" 8 | ) 9 | 10 | const ( 11 | ProjectsJira = "/rest/api/3/project/search" 12 | ProjectsByKeyJira = "/rest/api/3/project/%s" 13 | ) 14 | 15 | type Project struct { 16 | Id string `json:"id"` 17 | Name string `json:"name"` 18 | Key string `json:"key"` 19 | } 20 | 21 | type searchProjectsQueryParams struct { 22 | MaxResults int32 `url:"maxResults"` 23 | StartAt int32 `url:"startAt"` 24 | } 25 | 26 | type searchProjectsResponse struct { 27 | MaxResults int32 `json:"maxResults"` 28 | StartAt int32 `json:"startAt"` 29 | Values []Project `json:"values"` 30 | } 31 | 32 | var ( 33 | ProjectNotFoundError = errors.New("Project not found.") 34 | ) 35 | 36 | func (api *httpApi) FindProjects() ([]Project, error) { 37 | params := &searchProjectsQueryParams{} 38 | params.MaxResults = 100 39 | response, err := api.jiraRequest("GET", ProjectsJira, params, nil) 40 | if err != nil { 41 | return nil, err 42 | } 43 | var projects searchProjectsResponse 44 | if err := json.Unmarshal(response, &projects); err != nil { 45 | return nil, err 46 | } 47 | return projects.Values, nil 48 | } 49 | 50 | func (api *httpApi) FindProject(projectKey string) (*Project, error) { 51 | u := fmt.Sprintf(ProjectsByKeyJira, url.QueryEscape(projectKey)) 52 | response, err := api.jiraRequest("GET", u, nil, nil) 53 | if err != nil { 54 | return nil, err 55 | } 56 | var project *Project 57 | if err := json.Unmarshal(response, &project); err != nil { 58 | return nil, err 59 | } 60 | if project == nil { 61 | return nil, ProjectNotFoundError 62 | } 63 | return project, nil 64 | } 65 | -------------------------------------------------------------------------------- /internal/issues/goto_test.go: -------------------------------------------------------------------------------- 1 | package issues 2 | 3 | import ( 4 | "github.com/mk-5/fjira/internal/app" 5 | "github.com/mk-5/fjira/internal/jira" 6 | assert2 "github.com/stretchr/testify/assert" 7 | "testing" 8 | ) 9 | 10 | func TestGoIntoIssues(t *testing.T) { 11 | app.InitTestApp(nil) 12 | RegisterGoTo() 13 | 14 | type args struct { 15 | gotoMethod func() 16 | viewPredicate func() bool 17 | } 18 | tests := []struct { 19 | name string 20 | args args 21 | }{ 22 | {"should switch view into jql view", args{ 23 | gotoMethod: func() { app.GoTo("jql", jira.NewJiraApiMock(nil)) }, 24 | viewPredicate: func() bool { 25 | return app.CurrentScreenName() == "jql" 26 | }, 27 | }}, 28 | {"should switch view into search issues view", args{ 29 | gotoMethod: func() { app.GoTo("issues-search", "ABC", nil, jira.NewJiraApiMock(nil)) }, 30 | viewPredicate: func() bool { 31 | return app.CurrentScreenName() == "issues-search" 32 | }, 33 | }}, 34 | {"should switch view into issue view", args{ 35 | gotoMethod: func() { app.GoTo("issue", "ABC-123", nil, jira.NewJiraApiMock(nil)) }, 36 | viewPredicate: func() bool { 37 | return app.CurrentScreenName() == "issue" 38 | }, 39 | }}, 40 | {"should switch view into issues view with jql", args{ 41 | gotoMethod: func() { app.GoTo("issues-search-jql", "test jql", func() {}, jira.NewJiraApiMock(nil)) }, 42 | viewPredicate: func() bool { 43 | return app.CurrentScreenName() == "issues-search-jql" 44 | }, 45 | }}, 46 | } 47 | for _, tt := range tests { 48 | t.Run(tt.name, func(t *testing.T) { 49 | // when 50 | tt.args.gotoMethod() 51 | 52 | // then 53 | ok := tt.args.viewPredicate() 54 | assert2.New(t).True(ok, "Current view is invalid.") 55 | }) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /internal/jira/jira_request_test.go: -------------------------------------------------------------------------------- 1 | package jira 2 | 3 | import ( 4 | "github.com/stretchr/testify/assert" 5 | "net/http" 6 | "net/url" 7 | "testing" 8 | ) 9 | 10 | func Test_httpApi_jiraRequest_should_return_error_when_http_client_error(t *testing.T) { 11 | // given 12 | api := &httpApi{ 13 | client: &http.Client{ 14 | Transport: &authInterceptor{core: http.DefaultTransport, token: "test"}, 15 | }, 16 | restUrl: &url.URL{}, 17 | } 18 | 19 | // when 20 | _, err := api.jiraRequest("POST", "test", struct{}{}, nil) 21 | 22 | // then 23 | assert.NotNil(t, err) 24 | } 25 | 26 | func Test_httpApi_jiraRequest_should_return_error_when_invalid_params(t *testing.T) { 27 | // given 28 | api := &httpApi{ 29 | client: &http.Client{ 30 | Transport: &authInterceptor{core: http.DefaultTransport, token: "test"}, 31 | }, 32 | restUrl: &url.URL{}, 33 | } 34 | 35 | // when 36 | _, err := api.jiraRequest("POST", "test", "invalid params", nil) 37 | 38 | // then 39 | assert.NotNil(t, err) 40 | } 41 | 42 | func Test_jiraRequest_combinePaths(t *testing.T) { 43 | tests := []struct { 44 | name string 45 | apiUrl string 46 | restPath string 47 | wanted string 48 | }{ 49 | {"should add label without error", "http://localhost", "/api1", "http://localhost/api1"}, 50 | {"should add label without error", "http://localhost/", "/api1", "http://localhost/api1"}, 51 | {"should add label without error", "http://localhost/jira-api-v2", "/api1", "http://localhost/jira-api-v2/api1"}, 52 | } 53 | for _, tt := range tests { 54 | t.Run(tt.name, func(t *testing.T) { 55 | u, _ := url.Parse(tt.apiUrl) 56 | api := &httpApi{restUrl: u} 57 | 58 | result, _ := api.jiraRequestUrl(tt.restPath, nil) 59 | 60 | assert.Equal(t, tt.wanted, result) 61 | }) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /internal/app/draw.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "github.com/gdamore/tcell/v2" 5 | ) 6 | 7 | const ( 8 | EmptyLine = "" 9 | ) 10 | 11 | func DrawText(screen tcell.Screen, x, y int, style tcell.Style, text string) { 12 | row := y 13 | col := x 14 | for _, r := range text { 15 | if r == '\n' { 16 | row++ 17 | col = x 18 | continue 19 | } 20 | screen.SetContent(col, row, r, nil, style) 21 | col++ 22 | } 23 | } 24 | 25 | func DrawTextLimited(s tcell.Screen, x1, y1, x2, y2 int, style tcell.Style, text string) int { 26 | row := y1 27 | col := x1 28 | for _, r := range text { 29 | if r == '\n' { 30 | row++ 31 | col = x1 32 | continue 33 | } 34 | if s != nil { 35 | s.SetContent(col, row, r, nil, style) 36 | } 37 | col++ 38 | if col >= x2 { 39 | row++ 40 | col = x1 41 | } 42 | if row > y2 { 43 | break 44 | } 45 | } 46 | return row 47 | } 48 | 49 | func DrawBox(screen tcell.Screen, x1, y1, x2, y2 int, style tcell.Style) { 50 | if y2 < y1 { 51 | y1, y2 = y2, y1 52 | } 53 | if x2 < x1 { 54 | x1, x2 = x2, x1 55 | } 56 | 57 | // Draw borders 58 | for col := x1; col <= x2; col++ { 59 | screen.SetContent(col, y1, tcell.RuneHLine, nil, style) 60 | screen.SetContent(col, y2, tcell.RuneHLine, nil, style) 61 | } 62 | for row := y1 + 1; row < y2; row++ { 63 | screen.SetContent(x1, row, tcell.RuneVLine, nil, style) 64 | screen.SetContent(x2, row, tcell.RuneVLine, nil, style) 65 | } 66 | 67 | // Only draw corners if necessary 68 | if y1 != y2 && x1 != x2 { 69 | screen.SetContent(x1, y1, tcell.RuneULCorner, nil, style) 70 | screen.SetContent(x2, y1, tcell.RuneURCorner, nil, style) 71 | screen.SetContent(x1, y2, tcell.RuneLLCorner, nil, style) 72 | screen.SetContent(x2, y2, tcell.RuneLRCorner, nil, style) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to fjira 2 | 3 | 🚀 Thanks for taking the time to look here! 🚀 4 | 5 | In short - everyone is welcomed. Feel free to open PR with feature request or a bug fix. 6 | 7 | ### Branch name convention 8 | 9 | `feature|fix|misc|docs|refactor|build/short-branch-name` 10 | 11 | ### Commit message convention 12 | 13 | `feat|fix|misc|docs|refactor|build: my commit message goes here` 14 | 15 | ### Project structure 16 | 17 | It's a classic golang project, with organized directories structure. 18 | The most important directory is `internal` - it contains all the source code. 19 | Why internal? because - at least for now - fjira is not intended to be a shared go module. It's just an app that makes 20 | corporate rats (like me) life easier ;) 21 | 22 | Internal structure looks like this: 23 | 24 | ```text 25 | . 26 | ├── 📂 app 27 | │ └── ... 28 | | 📂 fjira 29 | │ └── ... 30 | | 📂 jira 31 | │ └── ... 32 | │ └── ... 33 | ``` 34 | 35 | #### app 36 | 37 | It contains application engine, so everything that's needed in order to do "the thing" - whatever the thing is 😅. 38 | You can notice that it doesn't contain any unit tests. There is a reason behind that. Just imagine that `app` module is 39 | a vehicle. There is no difference if you achieve your goal with ferrari, or old fiat. This is why it's not tested directly 40 | here, but by another modules that contains business logic. 41 | 42 | #### fjira 43 | 44 | The heart of application - the main context - business logic of our application. 45 | 46 | ##### jira 47 | 48 | Everything that's related to Jira, and Jira REST API. 49 | 50 | ##### other packages 51 | 52 | Packages should reflect purpose/domain of the package. Packages like `controllers` are fine if you like lasagne in your code 🤞. 53 | Example: 54 | 55 | - `views/users_view.go` ⛔️ 56 | - `users/view.go` ✅ 57 | -------------------------------------------------------------------------------- /internal/os/user_home_dir_test.go: -------------------------------------------------------------------------------- 1 | package os 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | ) 7 | 8 | func TestMustGetUserHomeDir_xdxPath(t *testing.T) { 9 | tests := []struct { 10 | name string 11 | }{ 12 | {"should return XDG path if available"}, 13 | } 14 | for _, tt := range tests { 15 | t.Run(tt.name, func(t *testing.T) { 16 | _ = os.Setenv("XDG_CONFIG_HOME", "abc") 17 | _ = SetUserHomeDir("something_for_just_for_test") 18 | if got := MustGetFjiraHomeDir(); got != "abc/fjira" { 19 | t.Errorf("MustGetUserHomeDir() = %v, want %v", got, "abc") 20 | } 21 | _ = os.Setenv("XDG_CONFIG_HOME", "") 22 | }) 23 | } 24 | } 25 | 26 | func TestMustGetUserHomeDir_userHomeWhenExist(t *testing.T) { 27 | tests := []struct { 28 | name string 29 | }{ 30 | {"should return userHome when exist, and XDG doesn't"}, 31 | } 32 | for _, tt := range tests { 33 | t.Run(tt.name, func(t *testing.T) { 34 | _ = os.Setenv("XDG_CONFIG_HOME", "abc") 35 | _ = SetUserHomeDir("./something_for_just_for_test") 36 | _ = os.MkdirAll("./something_for_just_for_test/.fjira", 0750) 37 | if got := MustGetFjiraHomeDir(); got != "./something_for_just_for_test/.fjira" { 38 | t.Errorf("MustGetUserHomeDir() = %v, want %v", got, "abc") 39 | } 40 | _ = os.Setenv("XDG_CONFIG_HOME", "") 41 | _ = os.RemoveAll("./something_for_just_for_test") 42 | }) 43 | } 44 | } 45 | 46 | func TestMustGetUserHomeDir_homePath(t *testing.T) { 47 | tests := []struct { 48 | name string 49 | }{ 50 | {"should return HOME path if available"}, 51 | } 52 | for _, tt := range tests { 53 | t.Run(tt.name, func(t *testing.T) { 54 | _ = SetUserHomeDir("user_home") 55 | if got := MustGetFjiraHomeDir(); got != "user_home/.fjira" { 56 | t.Errorf("MustGetUserHomeDir() = %v, want %v", got, "abc") 57 | } 58 | _ = os.Setenv("HOME", "") 59 | }) 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /internal/app/colors_test.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "fmt" 5 | "github.com/gdamore/tcell/v2" 6 | os2 "github.com/mk-5/fjira/internal/os" 7 | "github.com/stretchr/testify/assert" 8 | "os" 9 | "testing" 10 | ) 11 | 12 | func TestColor(t *testing.T) { 13 | MustLoadColorScheme() 14 | 15 | type args struct { 16 | c string 17 | } 18 | tests := []struct { 19 | name string 20 | args args 21 | want int32 22 | }{ 23 | {"should get color from default colors", args{c: "navigation.top.background"}, 6260575}, 24 | } 25 | for _, tt := range tests { 26 | t.Run(tt.name, func(t *testing.T) { 27 | assert.Equalf(t, tt.want, Color(tt.args.c).Hex(), "Color(%v)", tt.args.c) 28 | }) 29 | } 30 | } 31 | 32 | func TestMustLoadColorScheme(t *testing.T) { 33 | tempDir := t.TempDir() 34 | _ = os2.SetUserHomeDir(tempDir) 35 | _ = os.MkdirAll(fmt.Sprintf("%s/.fjira", tempDir), os.ModePerm) 36 | t.Cleanup(func() { 37 | _ = os.RemoveAll(tempDir) 38 | }) 39 | 40 | tests := []struct { 41 | name string 42 | }{ 43 | {"should load color scheme from user directory"}, 44 | } 45 | for _, tt := range tests { 46 | t.Run(tt.name, func(t *testing.T) { 47 | // given 48 | f := ` 49 | navigation: 50 | top: 51 | background: "#0000FF" 52 | foreground1: "#EEEEEE" 53 | 54 | ` 55 | p := fmt.Sprintf("%s/.fjira/colors.yml", tempDir) 56 | _ = os.WriteFile(p, []byte(f), 0644) 57 | _, err := os.Stat(p) 58 | assert.False(t, os.IsNotExist(err)) 59 | 60 | // when 61 | schemeMap = map[string]interface{}{} 62 | colorsMap = map[string]tcell.Color{} 63 | MustLoadColorScheme() 64 | 65 | // then 66 | assert.Equalf(t, int32(255), Color("navigation.top.background").Hex(), "MustLoadColorScheme()") 67 | assert.Equalf(t, int32(15658734), Color("navigation.top.foreground1").Hex(), "MustLoadColorScheme()") 68 | }) 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /internal/workspaces/settings_test.go: -------------------------------------------------------------------------------- 1 | package workspaces 2 | 3 | import ( 4 | "errors" 5 | os2 "github.com/mk-5/fjira/internal/os" 6 | "github.com/stretchr/testify/assert" 7 | "testing" 8 | ) 9 | 10 | func Test_userHomeSettingsStorage_write(t *testing.T) { 11 | type args struct { 12 | workspace string 13 | } 14 | tests := []struct { 15 | name string 16 | args args 17 | }{ 18 | {"should Write settings without error", args{workspace: "test2"}}, 19 | {"should Write settings without error", args{workspace: "test3"}}, 20 | } 21 | for _, tt := range tests { 22 | t.Run(tt.name, func(t *testing.T) { 23 | // given 24 | tempDir := t.TempDir() 25 | _ = os2.SetUserHomeDir(tempDir) 26 | s := &userHomeSettingsStorage{} 27 | settings := &WorkspaceSettings{JiraRestUrl: "http://test", JiraUsername: "test_user", JiraToken: "test_token"} 28 | filepath, _ := s.settingsFilePath() 29 | assert.NoFileExists(t, filepath) 30 | 31 | // when 32 | err := s.Write(tt.args.workspace, settings) 33 | 34 | // then 35 | assert.Nil(t, err) 36 | assert.FileExists(t, filepath) 37 | }) 38 | } 39 | } 40 | 41 | func Test_userHomeSettingsStorage_read(t *testing.T) { 42 | type args struct { 43 | workspace string 44 | } 45 | tests := []struct { 46 | name string 47 | args args 48 | }{ 49 | {"should return WorkspaceNotFoundErr if workspace doesn't exit", args{workspace: "test2"}}, 50 | } 51 | for _, tt := range tests { 52 | t.Run(tt.name, func(t *testing.T) { 53 | // given 54 | tempDir := t.TempDir() 55 | _ = os2.SetUserHomeDir(tempDir) 56 | s := &userHomeSettingsStorage{} 57 | filepath, _ := s.settingsFilePath() 58 | assert.NoFileExists(t, filepath) 59 | 60 | // when 61 | _, err := s.Read(tt.args.workspace) 62 | 63 | // then 64 | assert.True(t, errors.Is(err, WorkspaceNotFoundErr)) 65 | }) 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /internal/projects/search_projects_test.go: -------------------------------------------------------------------------------- 1 | package projects 2 | 3 | import ( 4 | "bytes" 5 | "net/http" 6 | "testing" 7 | "time" 8 | 9 | "github.com/gdamore/tcell/v2" 10 | "github.com/mk-5/fjira/internal/app" 11 | "github.com/mk-5/fjira/internal/jira" 12 | "github.com/stretchr/testify/assert" 13 | ) 14 | 15 | func TestNewProjectsSearchView(t *testing.T) { 16 | screen := tcell.NewSimulationScreen("utf-8") 17 | _ = screen.Init() //nolint:errcheck 18 | defer screen.Fini() 19 | app.InitTestApp(screen) 20 | RegisterGoto() 21 | tests := []struct { 22 | name string 23 | }{ 24 | {"should initialize & draw projects search view"}, 25 | } 26 | for _, tt := range tests { 27 | t.Run(tt.name, func(t *testing.T) { 28 | // given 29 | app.CreateNewAppWithScreen(screen) 30 | api := jira.NewJiraApiMock(func(w http.ResponseWriter, r *http.Request) { 31 | w.WriteHeader(200) 32 | _, _ = w.Write([]byte(`{"values":[{"id": "1", "name": "Test", "key": "TEST"}, {"id": "2", "name": "Fjira", "key":"FJIR"}]}`)) 33 | }) 34 | view := NewProjectsSearchView(api).(*searchProjectsView) 35 | 36 | // when 37 | view.Init() 38 | for view.fuzzyFind == nil { 39 | <-time.After(10 * time.Millisecond) 40 | } 41 | query := "FJIR" 42 | for _, key := range query { 43 | view.HandleKeyEvent(tcell.NewEventKey(-1, key, tcell.ModNone)) 44 | } 45 | i := 0 // keep app going for a while 46 | view.Resize(screen.Size()) 47 | for { 48 | view.Update() 49 | view.Draw(screen) 50 | i++ 51 | if i > 100000 { 52 | break 53 | } 54 | } 55 | 56 | // then 57 | 58 | var buffer bytes.Buffer 59 | contents, x, y := screen.GetContents() 60 | screen.Show() 61 | for i := 0; i < x*y; i++ { 62 | buffer.Write(contents[i].Bytes) 63 | } 64 | result := buffer.String() 65 | 66 | assert.Contains(t, result, "Fjira") 67 | assert.NotContains(t, result, "TEST") 68 | }) 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /internal/jira/jira_user.go: -------------------------------------------------------------------------------- 1 | package jira 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "github.com/mk-5/fjira/internal/app" 7 | ) 8 | 9 | type User struct { 10 | AccountId string `json:"accountId"` 11 | Active bool `json:"active"` 12 | AvatarUrls map[string]string `json:"avatarUrls"` 13 | DisplayName string `json:"displayName"` 14 | EmailAddress string `json:"emailAddress"` 15 | Locale string `json:"locale"` 16 | Self string `json:"self"` 17 | TimeZone string `json:"timeZone"` 18 | Key string `json:"key"` // field used by on-premise installation 19 | Name string `json:"name"` // field used by on-premise installation 20 | } 21 | 22 | const ( 23 | FindUser = "/rest/api/2/user/assignable/search" 24 | ) 25 | 26 | var UserSearchDeserializeErr = errors.New("Cannot deserialize jira user search response.") 27 | 28 | type findUserQueryParams struct { 29 | Project string `url:"project"` 30 | MaxResults int `url:"maxResults"` 31 | Query *string `url:"query"` 32 | Username *string `url:"username"` 33 | } 34 | 35 | func (api *httpApi) FindUsers(project string) ([]User, error) { 36 | return api.FindUsersWithQuery(project, "") 37 | } 38 | 39 | func (api *httpApi) FindUsersWithQuery(project string, query string) ([]User, error) { 40 | queryParams := &findUserQueryParams{ 41 | Project: project, 42 | MaxResults: 10000, 43 | } 44 | if query != "" && !api.IsJiraServer() { 45 | queryParams.Query = &query 46 | } 47 | if query != "" && api.IsJiraServer() { 48 | queryParams.Username = &query 49 | } 50 | response, err := api.jiraRequest("GET", FindUser, queryParams, nil) 51 | if err != nil { 52 | return nil, err 53 | } 54 | var users []User 55 | if err := json.Unmarshal(response, &users); err != nil { 56 | app.Error(err.Error()) 57 | return nil, UserSearchDeserializeErr 58 | } 59 | return users, nil 60 | } 61 | -------------------------------------------------------------------------------- /internal/issues/jql_builder_test.go: -------------------------------------------------------------------------------- 1 | package issues 2 | 3 | import ( 4 | "github.com/mk-5/fjira/internal/jira" 5 | "github.com/mk-5/fjira/internal/ui" 6 | "github.com/stretchr/testify/assert" 7 | "testing" 8 | ) 9 | 10 | func Test_buildSearchIssuesJql(t *testing.T) { 11 | type args struct { 12 | project *jira.Project 13 | query string 14 | status *jira.IssueStatus 15 | user *jira.User 16 | label string 17 | } 18 | tests := []struct { 19 | name string 20 | args args 21 | want string 22 | }{ 23 | {"should create valid jql", args{project: &jira.Project{Id: "123"}}, "project=123 ORDER BY status"}, 24 | {"should create valid jql", args{project: &jira.Project{Id: "123"}, query: "abc"}, "project=123 AND summary~\"abc*\" ORDER BY status"}, 25 | {"should create valid jql", args{project: &jira.Project{Id: ui.MessageAll, Key: ui.MessageAll}, query: "abc"}, "summary~\"abc*\" ORDER BY status"}, 26 | {"should create valid jql", args{ 27 | project: &jira.Project{Id: "123"}, query: "abc", status: &jira.IssueStatus{Id: "st1"}}, 28 | "project=123 AND summary~\"abc*\" AND status=st1 ORDER BY status", 29 | }, 30 | {"should create valid jql", args{ 31 | project: &jira.Project{Id: "123"}, query: "abc", status: &jira.IssueStatus{Id: "st1"}, user: &jira.User{AccountId: "us1"}}, 32 | "project=123 AND summary~\"abc*\" AND status=st1 AND assignee=us1 ORDER BY status", 33 | }, 34 | {"should create valid jql", args{project: &jira.Project{Id: "123"}, label: "test"}, "project=123 AND labels=test ORDER BY status"}, 35 | {"should create valid jql", args{project: &jira.Project{Id: "123"}, user: &jira.User{Name: "bob"}}, "project=123 AND assignee=bob ORDER BY status"}, 36 | } 37 | for _, tt := range tests { 38 | t.Run(tt.name, func(t *testing.T) { 39 | assert.Equalf(t, tt.want, BuildSearchIssuesJql(tt.args.project, tt.args.query, tt.args.status, tt.args.user, tt.args.label), "BuildSearchIssuesJql(%v, %v, %v, %v)", tt.args.project, tt.args.query, tt.args.status, tt.args.user) 40 | }) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /internal/workspaces/switch_workspace.go: -------------------------------------------------------------------------------- 1 | package workspaces 2 | 3 | import ( 4 | "fmt" 5 | "github.com/gdamore/tcell/v2" 6 | "github.com/mk-5/fjira/internal/app" 7 | "github.com/mk-5/fjira/internal/ui" 8 | "time" 9 | ) 10 | 11 | type switchWorkspaceView struct { 12 | fuzzyFind *app.FuzzyFind 13 | fjiraSettings SettingsStorage 14 | } 15 | 16 | func NewSwitchWorkspaceView() app.View { 17 | return &switchWorkspaceView{ 18 | fjiraSettings: NewUserHomeSettingsStorage(), 19 | } 20 | } 21 | 22 | func (s *switchWorkspaceView) Init() { 23 | records, err := s.fjiraSettings.ReadAllWorkspaces() 24 | if err != nil { 25 | panic(err.Error()) 26 | } 27 | s.fuzzyFind = app.NewFuzzyFind(ui.MessageSelectWorkspace, records) 28 | s.fuzzyFind.MarginBottom = 0 29 | app.GetApp().SetDirty() 30 | go s.waitForFuzzyFindComplete() 31 | } 32 | 33 | func (s *switchWorkspaceView) Destroy() { 34 | // do nothing 35 | } 36 | 37 | func (s *switchWorkspaceView) Update() { 38 | if s.fuzzyFind != nil { 39 | s.fuzzyFind.Update() 40 | } 41 | } 42 | 43 | func (s *switchWorkspaceView) Draw(screen tcell.Screen) { 44 | if s.fuzzyFind != nil { 45 | s.fuzzyFind.Draw(screen) 46 | } 47 | } 48 | 49 | func (s *switchWorkspaceView) Resize(screenX, screenY int) { 50 | if s.fuzzyFind != nil { 51 | s.fuzzyFind.Resize(screenX, screenY) 52 | } 53 | } 54 | 55 | func (s *switchWorkspaceView) HandleKeyEvent(keyEvent *tcell.EventKey) { 56 | if s.fuzzyFind != nil { 57 | s.fuzzyFind.HandleKeyEvent(keyEvent) 58 | } 59 | } 60 | 61 | func (s *switchWorkspaceView) waitForFuzzyFindComplete() { 62 | if workspace := <-s.fuzzyFind.Complete; true { 63 | if workspace.Index < 0 { 64 | app.GetApp().Quit() 65 | return 66 | } 67 | err := s.fjiraSettings.SetCurrentWorkspace(workspace.Match) 68 | if err != nil { 69 | app.Error(err.Error()) 70 | app.GetApp().Quit() 71 | return 72 | } 73 | app.Success(fmt.Sprintf(ui.MessageSelectWorkspaceSuccess, workspace.Match)) 74 | time.Sleep(2 * time.Second) 75 | app.GetApp().Quit() 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /internal/jira/jira_labels.go: -------------------------------------------------------------------------------- 1 | package jira 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/url" 7 | "strings" 8 | ) 9 | 10 | type LabelsSuggestionsResponseBody struct { 11 | Token string `json:"token"` 12 | Suggestions []struct { 13 | Label string `json:"label"` 14 | Html string `json:"html"` 15 | } `json:"suggestions"` 16 | } 17 | 18 | type labelRequestBody struct { 19 | Update struct { 20 | Labels []labelAdd `json:"labels"` 21 | } `json:"update"` 22 | } 23 | 24 | type labelAdd struct { 25 | Add string `json:"add"` 26 | } 27 | 28 | type findLabelsQueryParams struct { 29 | Query string `url:"query"` 30 | } 31 | 32 | const ( 33 | LabelsForIssuePath = "/rest/api/1.0/labels/%s/suggest" 34 | LabelsForProjectPath = "/rest/api/1.0/labels/suggest" 35 | DoLabelPath = "/rest/api/2/issue/%s" 36 | ) 37 | 38 | func (api *httpApi) FindLabels(issue *Issue, query string) ([]string, error) { 39 | path := LabelsForProjectPath 40 | if issue != nil { 41 | path = fmt.Sprintf(LabelsForIssuePath, url.QueryEscape(issue.Id)) 42 | } 43 | response, err := api.jiraRequest("GET", path, &findLabelsQueryParams{Query: query}, nil) 44 | if err != nil { 45 | return nil, err 46 | } 47 | var responseBody LabelsSuggestionsResponseBody 48 | if err := json.Unmarshal(response, &responseBody); err != nil { 49 | return nil, err 50 | } 51 | labels := make([]string, 0, len(responseBody.Suggestions)) 52 | for _, label := range responseBody.Suggestions { 53 | labels = append(labels, label.Label) 54 | } 55 | return labels, nil 56 | } 57 | 58 | func (api *httpApi) AddLabel(issueId string, label string) error { 59 | request := &labelRequestBody{} 60 | request.Update.Labels = make([]labelAdd, 0, 1) 61 | request.Update.Labels = append(request.Update.Labels, labelAdd{Add: label}) 62 | jsonBody, _ := json.Marshal(request) 63 | _, err := api.jiraRequest("PUT", fmt.Sprintf(DoLabelPath, url.QueryEscape(issueId)), &nilParams{}, strings.NewReader(string(jsonBody))) 64 | if err != nil { 65 | return err 66 | } 67 | return nil 68 | } 69 | -------------------------------------------------------------------------------- /internal/jira/jira_labels_test.go: -------------------------------------------------------------------------------- 1 | package jira 2 | 3 | import ( 4 | assert2 "github.com/stretchr/testify/assert" 5 | "net/http" 6 | "reflect" 7 | "testing" 8 | ) 9 | 10 | func Test_httpJiraApi_FindLabels(t *testing.T) { 11 | tests := []struct { 12 | name string 13 | want []string 14 | wantErr bool 15 | }{ 16 | {"should get labels without error", 17 | []string{"Design", "TestLabel", "Windows"}, 18 | false, 19 | }, 20 | } 21 | for _, tt := range tests { 22 | t.Run(tt.name, func(t *testing.T) { 23 | api := NewJiraApiMock(func(w http.ResponseWriter, r *http.Request) { 24 | w.WriteHeader(200) 25 | body := ` 26 | { 27 | "token": "", 28 | "suggestions": [ 29 | { 30 | "label": "Design", 31 | "html": "Design" 32 | }, 33 | { 34 | "label": "TestLabel", 35 | "html": "TestLabel" 36 | }, 37 | { 38 | "label": "Windows", 39 | "html": "Windows" 40 | } 41 | ] 42 | } 43 | ` 44 | _, _ = w.Write([]byte(body)) 45 | }) 46 | got, err := api.FindLabels(&Issue{}, "") 47 | if (err != nil) != tt.wantErr { 48 | t.Errorf("FindLabels() error = %v, wantErr %v", err, tt.wantErr) 49 | return 50 | } 51 | if !reflect.DeepEqual(got, tt.want) { 52 | t.Errorf("FindLabels() got = %v, want %v", got, tt.want) 53 | } 54 | }) 55 | } 56 | } 57 | 58 | func Test_httpJiraApi_AddLabel(t *testing.T) { 59 | tests := []struct { 60 | name string 61 | issueKey string 62 | label string 63 | wantErr bool 64 | }{ 65 | {"should add label without error", 66 | "PROJ-123", "Test", false, 67 | }, 68 | } 69 | for _, tt := range tests { 70 | t.Run(tt.name, func(t *testing.T) { 71 | api := NewJiraApiMock(func(w http.ResponseWriter, r *http.Request) { 72 | w.WriteHeader(200) 73 | body := "" 74 | w.Write([]byte(body)) //nolint:errcheck 75 | }) 76 | err := api.AddLabel(tt.issueKey, tt.label) 77 | assert2.Nil(t, err) 78 | }) 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /internal/jira/jira_search.go: -------------------------------------------------------------------------------- 1 | package jira 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "regexp" 8 | 9 | "github.com/mk-5/fjira/internal/app" 10 | ) 11 | 12 | const ( 13 | SearchJira = "/rest/api/3/search/jql" 14 | JiraIssueRegexp = "^[a-zA-Z0-9]{1,10}-[0-9]{1,20}$" 15 | ) 16 | 17 | var SearchDeserializeErr = errors.New("Cannot deserialize jira search response.") 18 | 19 | type searchQueryParams struct { 20 | Jql string `url:"jql"` 21 | MaxResults int32 `url:"maxResults"` 22 | Fields string `url:"fields"` 23 | StartAt int32 `url:"startAt"` 24 | } 25 | 26 | type searchResponse struct { 27 | Total int32 `json:"total"` 28 | MaxResults int32 `json:"maxResults"` 29 | Issues []Issue `json:"issues"` 30 | IsLast bool `json:"isLast"` 31 | } 32 | 33 | func (api *httpApi) Search(query string) ([]Issue, int32, error) { 34 | isJqlAboutIssue, _ := regexp.Match(JiraIssueRegexp, []byte(query)) 35 | jql := fmt.Sprintf("summary~\"%s*\"", query) 36 | if isJqlAboutIssue { 37 | jql = fmt.Sprintf("key=\"%s\"", query) 38 | } 39 | issues, total, _, err := api.SearchJqlPageable(jql, 0, 100) 40 | return issues, total, err 41 | } 42 | 43 | func (api *httpApi) SearchJql(jql string) ([]Issue, error) { 44 | issues, _, _, err := api.SearchJqlPageable(jql, 0, 100) 45 | return issues, err 46 | } 47 | 48 | func (api *httpApi) SearchJqlPageable(jql string, page int32, pageSize int32) ([]Issue, int32, int32, error) { 49 | queryParams := searchQueryParams{ 50 | Jql: jql, 51 | MaxResults: pageSize, 52 | StartAt: page * pageSize, 53 | Fields: "id,key,summary,issuetype,project,reporter,status,assignee", 54 | } 55 | body, err := api.jiraRequest("GET", SearchJira, queryParams, nil) 56 | if err != nil { 57 | return nil, -1, pageSize, err 58 | } 59 | var sResponse searchResponse 60 | if err := json.Unmarshal(body, &sResponse); err != nil { 61 | app.Error(err.Error()) 62 | return nil, -1, pageSize, SearchDeserializeErr 63 | } 64 | return sResponse.Issues, sResponse.Total, sResponse.MaxResults, err 65 | } 66 | -------------------------------------------------------------------------------- /internal/jira/jira_transitions.go: -------------------------------------------------------------------------------- 1 | package jira 2 | 3 | import ( 4 | "encoding/json" 5 | "github.com/mk-5/fjira/internal/app" 6 | "strings" 7 | ) 8 | 9 | // 10 | // https://developer.atlassian.com/cloud/jira/platform/rest/v3/api-group-issues/#api-rest-api-3-issue-issueidorkey-transitions-post 11 | // 12 | 13 | type Status struct { 14 | Id string `json:"id"` 15 | Name string `json:"name"` 16 | } 17 | 18 | type IssueTransition struct { 19 | Id string `json:"id"` 20 | Name string `json:"name"` 21 | To struct { 22 | StatusUrl string `json:"self"` 23 | StatusId string `json:"id"` 24 | Name string `json:"name"` 25 | } `json:"to"` 26 | } 27 | 28 | type IssueStatus struct { 29 | Id string `json:"id"` 30 | Name string `json:"name"` 31 | Description string `json:"description"` 32 | } 33 | 34 | const ( 35 | GetTransitions = "/rest/api/2/issue/{issue}/transitions" 36 | ) 37 | 38 | type nilParams struct{} 39 | 40 | type transitionsResponse struct { 41 | Transitions []IssueTransition `json:"transitions"` 42 | } 43 | 44 | type doTransitionRequest struct { 45 | Transition string `json:"transition"` 46 | } 47 | 48 | func (a *httpApi) DoTransition(issueId string, transition *IssueTransition) error { 49 | request := doTransitionRequest{Transition: transition.Id} 50 | requestBody, _ := json.Marshal(request) 51 | _, err := a.jiraRequest("POST", strings.Replace(GetTransitions, "{issue}", issueId, 1), &nilParams{}, strings.NewReader(string(requestBody))) 52 | if err != nil { 53 | return err 54 | } 55 | return nil 56 | } 57 | 58 | func (a *httpApi) FindTransitions(issueId string) ([]IssueTransition, error) { 59 | responseBody, _ := a.jiraRequest("GET", strings.Replace(GetTransitions, "{issue}", issueId, 1), &nilParams{}, nil) 60 | var sResponse transitionsResponse 61 | if err := json.Unmarshal(responseBody, &sResponse); err != nil { 62 | app.Error(err.Error()) 63 | return nil, SearchDeserializeErr 64 | } 65 | var transitions = make([]IssueTransition, 0, 1000) 66 | transitions = append(transitions, sResponse.Transitions...) 67 | return transitions, nil 68 | } 69 | -------------------------------------------------------------------------------- /internal/app/confirmation_test.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "bytes" 5 | "github.com/gdamore/tcell/v2" 6 | "github.com/stretchr/testify/assert" 7 | "strings" 8 | "testing" 9 | "time" 10 | ) 11 | 12 | func TestConfirm(t *testing.T) { 13 | screen := tcell.NewSimulationScreen("utf-8") 14 | _ = screen.Init() //nolint:errcheck 15 | defer screen.Fini() 16 | 17 | type args struct { 18 | message string 19 | } 20 | tests := []struct { 21 | name string 22 | args args 23 | }{ 24 | {"should render confirmation message", args{message: "Do you want to confirm xxx?"}}, 25 | } 26 | for _, tt := range tests { 27 | t.Run(tt.name, func(t *testing.T) { 28 | // given 29 | app := CreateNewAppWithScreen(screen) 30 | go Confirm(app, tt.args.message) 31 | <-time.NewTimer(100 * time.Millisecond).C 32 | 33 | // when 34 | app.Render() 35 | var buffer bytes.Buffer 36 | contents, x, y := screen.GetContents() 37 | screen.Show() 38 | for i := 0; i < x*y; i++ { 39 | if string(contents[i].Bytes) != "" { 40 | buffer.Write(contents[i].Bytes) 41 | } 42 | } 43 | result := strings.TrimSpace(buffer.String()) 44 | 45 | // then 46 | assert.Contains(t, result, tt.args.message) 47 | }) 48 | } 49 | } 50 | 51 | func TestConfirmation_HandleKeyEvent(t *testing.T) { 52 | type args struct { 53 | ev *tcell.EventKey 54 | } 55 | tests := []struct { 56 | name string 57 | args args 58 | want bool 59 | }{ 60 | {"should process ESC key", args{ev: tcell.NewEventKey(tcell.KeyEsc, 0, 0)}, false}, 61 | {"should process NO key", args{ev: tcell.NewEventKey(0, No, 0)}, false}, 62 | {"should process YES key", args{ev: tcell.NewEventKey(0, Yes, 0)}, true}, 63 | } 64 | for _, tt := range tests { 65 | t.Run(tt.name, func(t *testing.T) { 66 | completeChan := make(chan bool) 67 | c := &Confirmation{ 68 | Complete: completeChan, 69 | message: "abc", 70 | screenX: 2, 71 | screenY: 2, 72 | } 73 | go c.HandleKeyEvent(tt.args.ev) 74 | result := <-completeChan 75 | 76 | // then 77 | assert.Equal(t, tt.want, result) 78 | }) 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /.github/workflows/release-precheck.yml: -------------------------------------------------------------------------------- 1 | name: Goreleaser - Check 2 | on: 3 | push: 4 | branches: 5 | - master 6 | pull_request: 7 | 8 | permissions: 9 | contents: read 10 | 11 | jobs: 12 | goreleaser: 13 | strategy: 14 | matrix: 15 | go: [ 1.21 ] 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@v3 20 | with: 21 | fetch-depth: 0 22 | - name: install chocolatey 23 | run: | 24 | sudo apt install mono-complete -y 25 | mkdir -p /opt/chocolatey 26 | wget -q -O - "https://github.com/chocolatey/choco/releases/download/${CHOCOLATEY_VERSION}/chocolatey.v${CHOCOLATEY_VERSION}.tar.gz" | tar -xz -C "/opt/chocolatey" 27 | echo '#!/bin/bash' >> /usr/local/bin/choco 28 | echo 'mono /opt/chocolatey/choco.exe $@' >> /usr/local/bin/choco 29 | chmod +x /usr/local/bin/choco 30 | env: 31 | CHOCOLATEY_VERSION: 2.2.2 32 | - name: Install Snapcraft 33 | uses: samuelmeuli/action-snapcraft@v2 34 | - name: Set up Go 35 | uses: actions/setup-go@v3 36 | with: 37 | go-version: ${{ matrix.go }} 38 | - shell: bash 39 | env: 40 | GPG_KEY: ${{ secrets.GPG_KEY }} 41 | run: | 42 | echo $GPG_KEY | base64 --decode > "$HOME/.key" 43 | stat "$HOME/.key" 44 | mkdir -p $HOME/.cache/snapcraft/download 45 | mkdir -p $HOME/.cache/snapcraft/stage-packages 46 | - name: Run GoReleaser 47 | uses: goreleaser/goreleaser-action@v3 48 | with: 49 | distribution: goreleaser 50 | version: ${{ env.GITHUB_REF_NAME }} 51 | args: release --skip publish --snapshot --clean 52 | env: 53 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 54 | CHOCOLATEY_API_KEY: ${{ secrets.CHOCOLATEY_API_KEY }} 55 | SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_TOKEN }} 56 | AUR_KEY: ${{ secrets.AUR_KEY }} 57 | - shell: bash 58 | run: | 59 | rm -rf "$HOME/.key" 60 | if: always() 61 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Goreleaser 2 | on: 3 | push: 4 | tags: 5 | - '*' 6 | 7 | permissions: 8 | contents: write 9 | 10 | jobs: 11 | goreleaser: 12 | strategy: 13 | matrix: 14 | go: [ 1.21 ] 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v3 19 | with: 20 | fetch-depth: 0 21 | - name: install chocolatey 22 | run: | 23 | sudo apt install mono-complete -y 24 | mkdir -p /opt/chocolatey 25 | wget -q -O - "https://github.com/chocolatey/choco/releases/download/${CHOCOLATEY_VERSION}/chocolatey.v${CHOCOLATEY_VERSION}.tar.gz" | tar -xz -C "/opt/chocolatey" 26 | echo '#!/bin/bash' >> /usr/local/bin/choco 27 | echo 'mono /opt/chocolatey/choco.exe $@' >> /usr/local/bin/choco 28 | chmod +x /usr/local/bin/choco 29 | env: 30 | CHOCOLATEY_VERSION: 2.2.2 31 | - name: Install Snapcraft 32 | uses: samuelmeuli/action-snapcraft@v2 33 | - name: Set up Go 34 | uses: actions/setup-go@v3 35 | with: 36 | go-version: ${{ matrix.go }} 37 | - shell: bash 38 | env: 39 | GPG_KEY: ${{ secrets.GPG_KEY }} 40 | run: | 41 | echo $GPG_KEY | base64 --decode > "$HOME/.key" 42 | stat "$HOME/.key" 43 | mkdir -p $HOME/.cache/snapcraft/download 44 | mkdir -p $HOME/.cache/snapcraft/stage-packages 45 | - name: Run GoReleaser 46 | uses: goreleaser/goreleaser-action@v3 47 | with: 48 | distribution: goreleaser 49 | version: ${{ env.GITHUB_REF_NAME }} 50 | args: release --clean 51 | env: 52 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 53 | HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }} 54 | CHOCOLATEY_API_KEY: ${{ secrets.CHOCOLATEY_API_KEY }} 55 | SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_TOKEN }} 56 | AUR_KEY: ${{ secrets.AUR_KEY }} 57 | - shell: bash 58 | run: | 59 | rm -rf "$HOME/.key" 60 | if: always() 61 | -------------------------------------------------------------------------------- /internal/app/text_box.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import "github.com/gdamore/tcell/v2" 4 | 5 | type TextBox struct { 6 | x int 7 | y int 8 | x2 int 9 | y2 int 10 | text string 11 | textStyle tcell.Style 12 | bgStyle tcell.Style 13 | borderStyle tcell.Style 14 | } 15 | 16 | func NewTextBox(x, y int, style tcell.Style, borderStyle tcell.Style, text string) *TextBox { 17 | return &TextBox{ 18 | x: x, y: y, 19 | x2: x + len(text) + 3, 20 | y2: y - 2, 21 | textStyle: style, 22 | borderStyle: borderStyle, 23 | text: text, 24 | bgStyle: DefaultStyle(), 25 | } 26 | } 27 | 28 | func (t *TextBox) Draw(screen tcell.Screen) { 29 | if t.y2 < t.y { 30 | t.y, t.y2 = t.y2, t.y 31 | } 32 | if t.x2 < t.x { 33 | t.x, t.x2 = t.x2, t.x 34 | } 35 | 36 | // Fill background 37 | for row := t.y; row <= t.y2; row++ { 38 | for col := t.x; col <= t.x2; col++ { 39 | screen.SetContent(col, row, ' ', nil, t.bgStyle) 40 | } 41 | } 42 | // Draw borders 43 | for col := t.x; col <= t.x2; col++ { 44 | screen.SetContent(col, t.y, tcell.RuneHLine, nil, t.borderStyle) 45 | screen.SetContent(col, t.y2, tcell.RuneHLine, nil, t.borderStyle) 46 | } 47 | for row := t.y + 1; row < t.y2; row++ { 48 | screen.SetContent(t.x, row, tcell.RuneVLine, nil, t.borderStyle) 49 | screen.SetContent(t.x2, row, tcell.RuneVLine, nil, t.borderStyle) 50 | } 51 | 52 | // Only draw corners if necessary 53 | if t.y != t.y2 && t.x != t.x2 { 54 | screen.SetContent(t.x, t.y, tcell.RuneULCorner, nil, t.borderStyle) 55 | screen.SetContent(t.x2, t.y, tcell.RuneURCorner, nil, t.borderStyle) 56 | screen.SetContent(t.x, t.y2, tcell.RuneLLCorner, nil, t.borderStyle) 57 | screen.SetContent(t.x2, t.y2, tcell.RuneLRCorner, nil, t.borderStyle) 58 | } 59 | if t.text != "" { 60 | DrawText(screen, t.x+1, t.y+1, t.textStyle, " ") 61 | DrawText(screen, t.x+2, t.y+1, t.textStyle, t.text) 62 | DrawText(screen, t.x2-1, t.y+1, t.textStyle, " ") 63 | } 64 | } 65 | 66 | func (t *TextBox) SetX(x int) { 67 | t.x = x 68 | t.x2 = x + len(t.text) + 3 69 | } 70 | 71 | func (t *TextBox) SetY(y int) { 72 | t.y = y 73 | } 74 | 75 | func (t *TextBox) SetText(text string) { 76 | t.text = text 77 | t.SetX(t.x) 78 | } 79 | -------------------------------------------------------------------------------- /internal/issues/formatter.go: -------------------------------------------------------------------------------- 1 | package issues 2 | 3 | import ( 4 | "fmt" 5 | "github.com/mk-5/fjira/internal/app" 6 | "github.com/mk-5/fjira/internal/jira" 7 | "github.com/mk-5/fjira/internal/ui" 8 | "strconv" 9 | "strings" 10 | ) 11 | 12 | func FormatJiraIssue(issue *jira.Issue) string { 13 | return fmt.Sprintf("%s %s [%s] - %s", 14 | issue.Key, 15 | issue.Fields.Summary, 16 | issue.Fields.Status.Name, 17 | FormatAssignee(issue)) 18 | } 19 | 20 | func FormatJiraIssueTable(issue *jira.Issue, summaryColWidth int, statusColWidth int) string { 21 | assignee := issue.Fields.Assignee.DisplayName 22 | if assignee == "" { 23 | assignee = ui.MessageUnassigned 24 | } 25 | summaryColWidth = app.MinInt(summaryColWidth, ui.MaxSummaryColWidth) 26 | summaryCut := app.MinInt(summaryColWidth, len(issue.Fields.Summary)) 27 | statusColWidth = app.MinInt(statusColWidth, ui.MaxStatusColWidth) 28 | statusCut := app.MinInt(statusColWidth, len(issue.Fields.Status.Name)) 29 | return fmt.Sprintf("%10s %"+strconv.Itoa(summaryColWidth+ui.TableColumnPadding)+"s %"+strconv.Itoa(statusColWidth+4+ui.TableColumnPadding)+"s %s", 30 | issue.Key, 31 | issue.Fields.Summary[:summaryCut], 32 | fmt.Sprintf("[%s]", strings.ToUpper(issue.Fields.Status.Name[:statusCut])), 33 | fmt.Sprintf("- %s", assignee)) 34 | } 35 | 36 | func FormatJiraIssues(issues []jira.Issue) []string { 37 | formatted := make([]string, 0, len(issues)) 38 | summaryColWidth := findIssueColumnSize(&issues, func(i jira.Issue) string { 39 | return i.Fields.Summary 40 | }) 41 | statusColWidth := findIssueColumnSize(&issues, func(i jira.Issue) string { 42 | return i.Fields.Status.Name 43 | }) 44 | for _, issue := range issues { 45 | formatted = append(formatted, FormatJiraIssueTable(&issue, summaryColWidth, statusColWidth)) 46 | } 47 | return formatted 48 | } 49 | 50 | func FormatAssignee(issue *jira.Issue) string { 51 | assignee := issue.Fields.Assignee.DisplayName 52 | if assignee == "" { 53 | assignee = ui.MessageUnassigned 54 | } 55 | return assignee 56 | } 57 | 58 | func findIssueColumnSize(items *[]jira.Issue, colSupplier func(issue jira.Issue) string) int { 59 | max := 0 60 | for _, item := range *items { 61 | current := colSupplier(item) 62 | if max < len(current) { 63 | max = len(current) 64 | } 65 | } 66 | return max 67 | } 68 | -------------------------------------------------------------------------------- /internal/users/fuzzy_test.go: -------------------------------------------------------------------------------- 1 | package users 2 | 3 | import ( 4 | "github.com/gdamore/tcell/v2" 5 | "github.com/mk-5/fjira/internal/app" 6 | "github.com/mk-5/fjira/internal/jira" 7 | "github.com/stretchr/testify/assert" 8 | "net/http" 9 | "strings" 10 | "testing" 11 | ) 12 | 13 | func TestNewFuzzyFind(t *testing.T) { 14 | screen := tcell.NewSimulationScreen("utf-8") 15 | _ = screen.Init() //nolint:errcheck 16 | defer screen.Fini() 17 | app.InitTestApp(screen) 18 | 19 | tests := []struct { 20 | name string 21 | }{ 22 | {"should use api find up to typeaheadThreshold, then fuzzy-find"}, 23 | } 24 | for _, tt := range tests { 25 | t.Run(tt.name, func(t *testing.T) { 26 | // given 27 | apiCall := false 28 | sb2User := strings.Builder{} 29 | sb2User.WriteString(`[{"id": "U1", "displayName": "Bob"}, {"id": "U2", "displayName": "John"}]`) 30 | sb1000Users := strings.Builder{} 31 | sb1000Users.WriteString("[") 32 | for i := 0; i < 1000; i++ { 33 | sb1000Users.WriteString(`{"id": "U1", "displayName": "Bob"}`) 34 | if i != 999 { 35 | sb1000Users.WriteString(",") 36 | } 37 | } 38 | sb1000Users.WriteString("]") 39 | var apiResult *strings.Builder 40 | api := jira.NewJiraApiMock(func(w http.ResponseWriter, r *http.Request) { 41 | apiCall = true 42 | w.WriteHeader(200) 43 | _, _ = w.Write([]byte(apiResult.String())) 44 | }) 45 | fuzzyFind, us := NewFuzzyFind("ABC", api) 46 | fuzzyFind.SetDebounceDisabled(true) 47 | 48 | // when 49 | apiCall = false 50 | apiResult = &sb1000Users 51 | fuzzyFind.SetQuery("") 52 | fuzzyFind.Update() 53 | 54 | // then 55 | assert.True(t, apiCall) 56 | assert.Equal(t, 1001, len(*us)) 57 | 58 | // when 59 | apiCall = false 60 | apiResult = &sb1000Users 61 | fuzzyFind.SetQuery("b") 62 | fuzzyFind.Update() 63 | 64 | // then 65 | assert.True(t, apiCall) 66 | assert.Equal(t, 1001, len(*us)) 67 | 68 | // when 69 | apiCall = false 70 | apiResult = &sb2User 71 | fuzzyFind.SetQuery("bo") 72 | fuzzyFind.Update() 73 | 74 | // then 75 | assert.True(t, apiCall) 76 | assert.Equal(t, 3, len(*us)) 77 | 78 | // when 79 | apiCall = false 80 | apiResult = &sb2User 81 | fuzzyFind.SetQuery("bo") 82 | fuzzyFind.Update() 83 | 84 | // then 85 | assert.False(t, apiCall, "api shouldn't be called because previous call returned 2 records") 86 | assert.Equal(t, 3, len(*us)) 87 | }) 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /internal/jira/jira_transitions_test.go: -------------------------------------------------------------------------------- 1 | package jira 2 | 3 | import ( 4 | "net/http" 5 | "reflect" 6 | "testing" 7 | ) 8 | 9 | func Test_httpJiraApi_DoTransition(t *testing.T) { 10 | type args struct { 11 | issueId string 12 | transition *IssueTransition 13 | } 14 | tests := []struct { 15 | name string 16 | args args 17 | wantErr bool 18 | }{ 19 | {"should do transition without error", 20 | args{transition: &IssueTransition{ 21 | Id: "test", 22 | Name: "test", 23 | To: struct { 24 | StatusUrl string `json:"self"` 25 | StatusId string `json:"id"` 26 | Name string `json:"name"` 27 | }(struct { 28 | StatusUrl string 29 | StatusId string 30 | Name string 31 | }{"test2", "test2", "test2"}), 32 | }, issueId: "ABC-123"}, 33 | false, 34 | }, 35 | } 36 | for _, tt := range tests { 37 | t.Run(tt.name, func(t *testing.T) { 38 | api := NewJiraApiMock(func(w http.ResponseWriter, r *http.Request) { 39 | w.WriteHeader(200) 40 | w.Write([]byte(``)) //nolint:errcheck 41 | }) 42 | if err := api.DoTransition(tt.args.issueId, tt.args.transition); (err != nil) != tt.wantErr { 43 | t.Errorf("DoTransition() error = %v, wantErr %v", err, tt.wantErr) 44 | } 45 | }) 46 | } 47 | } 48 | 49 | func Test_httpJiraApi_FindTransitions(t *testing.T) { 50 | type args struct { 51 | issueId string 52 | } 53 | tests := []struct { 54 | name string 55 | args args 56 | want []IssueTransition 57 | wantErr bool 58 | }{ 59 | {"should find transitions without error", 60 | args{issueId: "ABC-123"}, 61 | []IssueTransition{{Id: "11", Name: "To Do"}, {Id: "21", Name: "In Progress"}}, 62 | false, 63 | }, 64 | } 65 | for _, tt := range tests { 66 | t.Run(tt.name, func(t *testing.T) { 67 | api := NewJiraApiMock(func(w http.ResponseWriter, r *http.Request) { 68 | w.WriteHeader(200) 69 | body := ` 70 | { 71 | "transitions": [ 72 | { 73 | "id": "11", 74 | "name": "To Do" 75 | }, 76 | { 77 | "id": "21", 78 | "name": "In Progress" 79 | } 80 | ] 81 | } 82 | ` 83 | w.Write([]byte(body)) //nolint:errcheck 84 | }) 85 | got, err := api.FindTransitions(tt.args.issueId) 86 | if (err != nil) != tt.wantErr { 87 | t.Errorf("FindTransitions() error = %v, wantErr %v", err, tt.wantErr) 88 | return 89 | } 90 | if !reflect.DeepEqual(got, tt.want) { 91 | t.Errorf("FindTransitions() got = %v, want %v", got, tt.want) 92 | } 93 | }) 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /internal/projects/search_projects.go: -------------------------------------------------------------------------------- 1 | package projects 2 | 3 | import ( 4 | "github.com/gdamore/tcell/v2" 5 | "github.com/mk-5/fjira/internal/app" 6 | "github.com/mk-5/fjira/internal/jira" 7 | "github.com/mk-5/fjira/internal/ui" 8 | ) 9 | 10 | type searchProjectsView struct { 11 | api jira.Api 12 | bottomBar *app.ActionBar 13 | topBar *app.ActionBar 14 | fuzzyFind *app.FuzzyFind 15 | } 16 | 17 | func NewProjectsSearchView(api jira.Api) app.View { 18 | bottomBar := ui.CreateBottomActionBar(ui.MessageProjectLabel, app.ActionBarLabel("")) 19 | topBar := ui.CreateTopActionBar(ui.MessageProjectLabel, app.ActionBarLabel("")) 20 | return &searchProjectsView{ 21 | api: api, 22 | bottomBar: bottomBar, 23 | topBar: topBar, 24 | } 25 | } 26 | 27 | func (view *searchProjectsView) Init() { 28 | app.GetApp().LoadingWithText(true, ui.MessageSearchProjectsLoading) 29 | go view.runProjectsFuzzyFind() 30 | } 31 | 32 | func (view *searchProjectsView) Destroy() { 33 | } 34 | 35 | func (view *searchProjectsView) Draw(screen tcell.Screen) { 36 | if view.fuzzyFind != nil { 37 | view.fuzzyFind.Draw(screen) 38 | } 39 | } 40 | 41 | func (view *searchProjectsView) Update() { 42 | if view.fuzzyFind != nil { 43 | view.fuzzyFind.Update() 44 | } 45 | } 46 | 47 | func (view *searchProjectsView) Resize(screenX, screenY int) { 48 | if view.fuzzyFind != nil { 49 | view.fuzzyFind.Resize(screenX, screenY) 50 | } 51 | } 52 | 53 | func (view *searchProjectsView) HandleKeyEvent(ev *tcell.EventKey) { 54 | if view.fuzzyFind != nil { 55 | view.fuzzyFind.HandleKeyEvent(ev) 56 | } 57 | } 58 | 59 | func (view *searchProjectsView) findProjects() []jira.Project { 60 | projects, err := view.api.FindProjects() 61 | if err != nil { 62 | app.Error(err.Error()) 63 | } 64 | return projects 65 | } 66 | 67 | func (view *searchProjectsView) reopen() { 68 | app.GoTo("projects", view.api) 69 | } 70 | 71 | func (view *searchProjectsView) runProjectsFuzzyFind() { 72 | defer app.GetApp().PanicRecover() 73 | projects := view.findProjects() 74 | projects = append(projects, jira.Project{Id: ui.MessageAll, Name: ui.MessageAll, Key: ui.MessageAll}) 75 | projectsString := FormatJiraProjects(projects) 76 | view.fuzzyFind = app.NewFuzzyFind(ui.MessageSelectProject, projectsString) 77 | view.fuzzyFind.MarginBottom = 0 78 | app.GetApp().Loading(false) 79 | app.GetApp().ClearNow() 80 | if chosen := <-view.fuzzyFind.Complete; true { 81 | app.GetApp().ClearNow() 82 | if chosen.Index < 0 { 83 | app.GetApp().Quit() 84 | return 85 | } 86 | chosenProject := projects[chosen.Index] 87 | app.GoTo("issues-search", chosenProject.Id, view.reopen, view.api) 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /internal/issues/goto.go: -------------------------------------------------------------------------------- 1 | package issues 2 | 3 | import ( 4 | "github.com/mk-5/fjira/internal/app" 5 | "github.com/mk-5/fjira/internal/jira" 6 | "github.com/mk-5/fjira/internal/ui" 7 | "time" 8 | ) 9 | 10 | type SearchArgs struct { 11 | ProjectKey string 12 | GoBackFn func() 13 | Api jira.Api 14 | } 15 | 16 | const ( 17 | issue string = "issue" 18 | issuesSearch string = "issues-search" 19 | issuesSearchJql string = "issues-search-jql" 20 | jql string = "jql" 21 | ) 22 | 23 | func RegisterGoTo() { 24 | app.RegisterGoto(issue, func(args ...interface{}) { 25 | issueKey := args[0].(string) 26 | var goBackFn func() 27 | if fn, ok := args[1].(func()); ok { 28 | goBackFn = fn 29 | } 30 | api := args[2].(jira.Api) 31 | 32 | defer app.GetApp().PanicRecover() 33 | app.GetApp().Loading(true) 34 | issue, err := api.GetIssueDetailed(issueKey) 35 | if err != nil { 36 | app.GetApp().Loading(false) 37 | app.Error(err.Error()) 38 | return 39 | } 40 | app.GetApp().Loading(false) 41 | if goBackFn == nil { 42 | goBackFn = func() { 43 | app.GoTo(issuesSearch, issue.Fields.Project.Id, func() { 44 | app.GoTo("projects", api) 45 | }, api) 46 | } 47 | } 48 | issueView := NewIssueView(issue, goBackFn, api) 49 | app.GetApp().SetView(issueView) 50 | }) 51 | app.RegisterGoto(issuesSearch, func(args ...interface{}) { 52 | projectKey := args[0].(string) 53 | var goBackFn func() 54 | if fn, ok := args[1].(func()); ok { 55 | goBackFn = fn 56 | } 57 | api := args[2].(jira.Api) 58 | 59 | defer app.GetApp().PanicRecover() 60 | if projectKey == ui.MessageAll { 61 | project := &jira.Project{Id: ui.MessageAll, Name: ui.MessageAll, Key: ui.MessageAll} 62 | projectsView := NewIssuesSearchView(project, goBackFn, api) 63 | app.GetApp().SetView(projectsView) 64 | return 65 | } 66 | app.GetApp().Loading(true) 67 | project, err := api.FindProject(projectKey) 68 | if err != nil { 69 | app.Error(err.Error()) 70 | <-time.NewTimer(2 * time.Second).C 71 | app.GetApp().Quit() 72 | return 73 | } 74 | app.GetApp().Loading(false) 75 | projectsView := NewIssuesSearchView(project, goBackFn, api) 76 | app.GetApp().SetView(projectsView) 77 | }) 78 | app.RegisterGoto(issuesSearchJql, func(args ...interface{}) { 79 | defer app.GetApp().PanicRecover() 80 | jql := args[0].(string) 81 | var goBackFn func() 82 | if fn, ok := args[1].(func()); ok { 83 | goBackFn = fn 84 | } 85 | api := args[2].(jira.Api) 86 | issuesSearchView := NewIssuesSearchViewWithCustomJql(jql, goBackFn, api) 87 | app.GetApp().SetView(issuesSearchView) 88 | }) 89 | app.RegisterGoto(jql, func(args ...interface{}) { 90 | defer app.GetApp().PanicRecover() 91 | api := args[0].(jira.Api) 92 | jqlView := NewJqlSearchView(api) 93 | app.GetApp().SetView(jqlView) 94 | }) 95 | } 96 | -------------------------------------------------------------------------------- /internal/filters/filters_search.go: -------------------------------------------------------------------------------- 1 | package filters 2 | 3 | import ( 4 | "github.com/gdamore/tcell/v2" 5 | "github.com/mk-5/fjira/internal/app" 6 | "github.com/mk-5/fjira/internal/jira" 7 | "github.com/mk-5/fjira/internal/ui" 8 | ) 9 | 10 | type filtersSearchView struct { 11 | app.View 12 | api jira.Api 13 | bottomBar *app.ActionBar 14 | fuzzyFind *app.FuzzyFind 15 | } 16 | 17 | func NewFiltersView(api jira.Api) app.View { 18 | bottomBar := ui.CreateBottomLeftBar() 19 | bottomBar.AddItem(ui.NewCancelBarItem()) 20 | return &filtersSearchView{ 21 | api: api, 22 | bottomBar: bottomBar, 23 | } 24 | } 25 | 26 | func (view *filtersSearchView) Init() { 27 | go view.startFiltersFuzzyFind() 28 | go view.handleBottomBarActions() 29 | } 30 | 31 | func (view *filtersSearchView) Destroy() { 32 | view.bottomBar.Destroy() 33 | } 34 | 35 | func (view *filtersSearchView) Draw(screen tcell.Screen) { 36 | if view.fuzzyFind != nil { 37 | view.fuzzyFind.Draw(screen) 38 | } 39 | view.bottomBar.Draw(screen) 40 | } 41 | 42 | func (view *filtersSearchView) Update() { 43 | view.bottomBar.Update() 44 | if view.fuzzyFind != nil { 45 | view.fuzzyFind.Update() 46 | } 47 | } 48 | 49 | func (view *filtersSearchView) Resize(screenX, screenY int) { 50 | if view.fuzzyFind != nil { 51 | view.fuzzyFind.Resize(screenX, screenY) 52 | } 53 | view.bottomBar.Resize(screenX, screenY) 54 | } 55 | 56 | func (view *filtersSearchView) HandleKeyEvent(ev *tcell.EventKey) { 57 | view.bottomBar.HandleKeyEvent(ev) 58 | if view.fuzzyFind != nil { 59 | view.fuzzyFind.HandleKeyEvent(ev) 60 | } 61 | } 62 | 63 | func (view *filtersSearchView) startFiltersFuzzyFind() { 64 | app.GetApp().ClearNow() 65 | app.GetApp().Loading(true) 66 | filters, err := view.api.GetMyFilters() 67 | if err != nil { 68 | app.Error(err.Error()) 69 | return 70 | } 71 | view.fuzzyFind = app.NewFuzzyFind(ui.MessageSelectFilter, FormatFilters(filters)) 72 | view.fuzzyFind.MarginBottom = 1 73 | app.GetApp().Loading(false) 74 | if chosen := <-view.fuzzyFind.Complete; true { 75 | app.GetApp().ClearNow() 76 | if chosen.Index < 0 { 77 | view.reopen() 78 | return 79 | } 80 | if chosen.Index >= 0 { 81 | filter := filters[chosen.Index] 82 | app.GoTo("issues-search-jql", filter.JQL, view.reopen, view.api) 83 | } 84 | } 85 | } 86 | 87 | func (view *filtersSearchView) handleBottomBarActions() { 88 | for { 89 | action, ok := <-view.bottomBar.Action 90 | if !ok { 91 | return 92 | } 93 | switch action { 94 | case ui.ActionCancel: 95 | view.cancel() 96 | return 97 | } 98 | } 99 | } 100 | 101 | func (view *filtersSearchView) cancel() { 102 | app.GetApp().Quit() 103 | } 104 | 105 | func (view *filtersSearchView) reopen() { 106 | app.GoTo("filters", view.api) 107 | } 108 | -------------------------------------------------------------------------------- /internal/jira/jira.go: -------------------------------------------------------------------------------- 1 | package jira 2 | 3 | import ( 4 | "encoding/base64" 5 | "log" 6 | "net/http" 7 | "net/url" 8 | ) 9 | 10 | type Api interface { 11 | Search(query string) ([]Issue, int32, error) 12 | SearchJql(query string) ([]Issue, error) 13 | SearchJqlPageable(query string, page int32, pageSize int32) ([]Issue, int32, int32, error) 14 | FindUsers(project string) ([]User, error) 15 | FindUsersWithQuery(project string, query string) ([]User, error) 16 | FindProjects() ([]Project, error) 17 | FindLabels(issue *Issue, query string) ([]string, error) 18 | AddLabel(issueId string, label string) error 19 | FindProject(projectKey string) (*Project, error) 20 | FindTransitions(issueId string) ([]IssueTransition, error) 21 | FindProjectStatuses(projectId string) ([]IssueStatus, error) 22 | DoTransition(issueId string, transition *IssueTransition) error 23 | DoAssignee(issueId string, user *User) error 24 | GetIssueDetailed(issueId string) (*Issue, error) 25 | DoComment(issueId string, commentBody string) error 26 | FindBoards(projectKeyOrId string) ([]BoardItem, error) 27 | GetBoardConfiguration(boardId int) (*BoardConfiguration, error) 28 | GetBoardSprints(boardId int) ([]SprintItem, error) 29 | GetBoardSprintIssues(boardId int, sprintId int, page int32, pageSize int32) ([]Issue, int32, int32, error) 30 | GetFilter(filterId string) (*Filter, error) 31 | GetMyFilters() ([]Filter, error) 32 | Close() 33 | GetApiUrl() string 34 | 35 | IsJiraServer() bool 36 | } 37 | 38 | type ApiCredentials struct { 39 | Host string 40 | ApiKey string 41 | } 42 | 43 | type JiraTokenType string 44 | 45 | const ( 46 | ApiToken JiraTokenType = "api token" 47 | PersonalToken JiraTokenType = "personal token" 48 | ) 49 | 50 | type httpApi struct { 51 | apiUrl string 52 | tokenType JiraTokenType 53 | client *http.Client 54 | restUrl *url.URL 55 | } 56 | 57 | func NewApi(apiUrl string, username string, token string, tokenType JiraTokenType) (Api, error) { 58 | baseUrl, err := url.Parse(apiUrl) 59 | if err != nil { 60 | log.Fatalln(err) 61 | } 62 | var authToken string 63 | var authType AuthType 64 | switch tokenType { 65 | case PersonalToken: 66 | authToken = token 67 | authType = Bearer 68 | default: 69 | authToken = base64.StdEncoding.EncodeToString([]byte(username + ":" + token)) 70 | authType = Basic 71 | } 72 | return &httpApi{ 73 | apiUrl: apiUrl, 74 | tokenType: tokenType, 75 | client: &http.Client{ 76 | Transport: &authInterceptor{core: defaultHttpTransport, token: authToken, authType: authType}, 77 | }, 78 | restUrl: baseUrl, 79 | }, nil 80 | } 81 | 82 | func (api *httpApi) GetApiUrl() string { 83 | return api.apiUrl 84 | } 85 | 86 | func (api *httpApi) IsJiraServer() bool { 87 | // for now - just a stupid impl like this 88 | return api.tokenType == PersonalToken 89 | } 90 | 91 | func (api *httpApi) Close() { 92 | api.client.CloseIdleConnections() 93 | } 94 | -------------------------------------------------------------------------------- /internal/ui/text_writer_view.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "bytes" 5 | "github.com/gdamore/tcell/v2" 6 | "github.com/mk-5/fjira/internal/app" 7 | "unicode" 8 | ) 9 | 10 | // TODO - should be here? 11 | type TextWriterView struct { 12 | app.View 13 | bottomBar *app.ActionBar 14 | buffer bytes.Buffer 15 | text string 16 | headerStyle tcell.Style 17 | style tcell.Style 18 | args TextWriterArgs 19 | } 20 | 21 | type TextWriterArgs struct { 22 | MaxLength int 23 | TextConsumer func(string) 24 | GoBack func() 25 | Header string 26 | } 27 | 28 | func NewTextWriterView(args *TextWriterArgs) app.View { 29 | bottomBar := CreateBottomLeftBar() 30 | bottomBar.AddItem(NewSaveBarItem()) 31 | bottomBar.AddItem(NewCancelBarItem()) 32 | if args.MaxLength == 0 { 33 | args.MaxLength = 150 34 | } 35 | if args.GoBack == nil { 36 | args.GoBack = func() { 37 | } 38 | } 39 | if args.TextConsumer == nil { 40 | args.TextConsumer = func(str string) { 41 | } 42 | } 43 | return &TextWriterView{ 44 | bottomBar: bottomBar, 45 | text: "", 46 | args: *args, 47 | headerStyle: app.DefaultStyle().Foreground(app.Color("default.foreground2")).Underline(true), 48 | style: app.DefaultStyle(), 49 | } 50 | } 51 | 52 | func (view *TextWriterView) Init() { 53 | go view.handleBottomBarActions() 54 | } 55 | 56 | func (view *TextWriterView) Destroy() { 57 | view.bottomBar.Destroy() 58 | } 59 | 60 | func (view *TextWriterView) Draw(screen tcell.Screen) { 61 | app.DrawText(screen, 1, 2, view.headerStyle, view.args.Header) 62 | app.DrawTextLimited(screen, 1, 4, view.args.MaxLength, 100, view.style, view.text) 63 | view.bottomBar.Draw(screen) 64 | } 65 | 66 | func (view *TextWriterView) Update() { 67 | view.bottomBar.Update() 68 | } 69 | 70 | func (view *TextWriterView) Resize(screenX, screenY int) { 71 | view.bottomBar.Resize(screenX, screenY) 72 | } 73 | 74 | func (view *TextWriterView) HandleKeyEvent(ev *tcell.EventKey) { 75 | view.bottomBar.HandleKeyEvent(ev) 76 | if (unicode.IsLetter(ev.Rune()) || unicode.IsDigit(ev.Rune()) || unicode.IsSpace(ev.Rune()) || 77 | unicode.IsPunct(ev.Rune()) || unicode.IsSymbol(ev.Rune())) && !isBackspace(ev) { 78 | view.buffer.WriteRune(ev.Rune()) 79 | } 80 | if ev.Key() == tcell.KeyEnter { 81 | view.buffer.WriteRune('\n') 82 | } 83 | if isBackspace(ev) { 84 | if view.buffer.Len() > 0 { 85 | view.buffer.Truncate(view.buffer.Len() - 1) 86 | } 87 | } 88 | view.text = view.buffer.String() 89 | } 90 | 91 | func (view *TextWriterView) handleBottomBarActions() { 92 | action := <-view.bottomBar.Action 93 | switch action { 94 | case ActionYes: 95 | view.args.TextConsumer(view.buffer.String()) 96 | } 97 | go view.args.GoBack() 98 | } 99 | 100 | func isBackspace(ev *tcell.EventKey) bool { 101 | return ev.Key() == tcell.KeyBackspace || ev.Key() == tcell.KeyBackspace2 || ev.Key() == tcell.KeyBS 102 | } 103 | -------------------------------------------------------------------------------- /internal/app/colors.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "fmt" 5 | "github.com/gdamore/tcell/v2" 6 | os2 "github.com/mk-5/fjira/internal/os" 7 | "gopkg.in/yaml.v3" 8 | "os" 9 | ) 10 | 11 | var ( 12 | schemeMap map[string]interface{} 13 | colorsMap = map[string]tcell.Color{} 14 | ) 15 | 16 | func Color(c string) tcell.Color { 17 | if len(colorsMap) == 0 { 18 | MustLoadColorScheme() 19 | } 20 | if color, ok := colorsMap[c]; ok { 21 | return color 22 | } 23 | panic("unknown color " + c) 24 | } 25 | 26 | func MustLoadColorScheme() map[string]interface{} { 27 | d := os2.MustGetFjiraHomeDir() 28 | p := fmt.Sprintf("%s/colors.yml", d) 29 | b, err := os.ReadFile(p) 30 | if err != nil { 31 | schemeMap = parseYMLStr(defaultColorsYML()) 32 | } else { 33 | schemeMap = parseYMLStr(string(b)) 34 | } 35 | colorsMap = parseYamlToDotNotationMap("", schemeMap, colorsMap) 36 | return schemeMap 37 | } 38 | 39 | func parseYamlToDotNotationMap(prefix string, yml map[string]interface{}, targetMap map[string]tcell.Color) map[string]tcell.Color { 40 | var key string 41 | for k, v := range yml { 42 | if prefix == "" { 43 | key = k 44 | } else { 45 | key = fmt.Sprintf("%s.%s", prefix, k) 46 | } 47 | if m, ok := v.(map[string]interface{}); ok { 48 | targetMap = parseYamlToDotNotationMap(key, m, targetMap) 49 | } 50 | if h, ok := v.(string); ok { 51 | targetMap[key] = tcell.GetColor(h) 52 | } 53 | } 54 | return targetMap 55 | } 56 | 57 | func parseYMLStr(y string) map[string]interface{} { 58 | var yml map[string]interface{} 59 | err := yaml.Unmarshal([]byte(y), &yml) 60 | if err != nil { 61 | Error(err.Error()) 62 | return map[string]interface{}{} 63 | } 64 | return yml 65 | } 66 | 67 | func defaultColorsYML() string { 68 | return ` 69 | default: 70 | background: "#161616" 71 | foreground: "#c7c7c7" 72 | foreground2: "#FFFFFF" 73 | 74 | finder: 75 | cursor: "#8B0000" 76 | title: "#E9CE58" 77 | match: "#90EE90" 78 | highlight: 79 | background: "#3A3A3A" 80 | foreground: "#FFFFFF" 81 | match: "#E0FFFF" 82 | 83 | navigation: 84 | top: 85 | background: "#5F875f" 86 | foreground1: "#FFFFFF" 87 | foreground2: "#151515" 88 | bottom: 89 | background: "#5F87AF" 90 | foreground1: "#FFFFFF" 91 | foreground2: "#151515" 92 | 93 | details: 94 | foreground: "#696969" 95 | 96 | boards: 97 | title: 98 | foreground: "#ecce58" 99 | headers: 100 | background: "#5F875f" 101 | foreground: "#FFFFFF" 102 | column: 103 | background: "#232323" 104 | foreground: "#ffffff" 105 | highlight: 106 | background: "#484848" 107 | foreground: "#ffffff" 108 | selection: 109 | background: "#8B0000" 110 | foreground: "#ffffff" 111 | 112 | spinner: 113 | accent: "#FF0000" 114 | 115 | alerts: 116 | success: 117 | background: "#F5F5F5" 118 | foreground: "#006400" 119 | error: 120 | background: "#F5F5F5" 121 | foreground: "#8B0000" 122 | ` 123 | } 124 | -------------------------------------------------------------------------------- /internal/issues/jql_search.go: -------------------------------------------------------------------------------- 1 | package issues 2 | 3 | import ( 4 | "github.com/gdamore/tcell/v2" 5 | "github.com/mk-5/fjira/internal/app" 6 | "github.com/mk-5/fjira/internal/jira" 7 | "github.com/mk-5/fjira/internal/ui" 8 | "strings" 9 | "time" 10 | ) 11 | 12 | const ( 13 | DefaultJqlQuery = "created >= -30d order by created DESC" 14 | BadRequest = "Bad Request" 15 | ) 16 | 17 | type jqlSearchView struct { 18 | app.View 19 | api jira.Api 20 | fuzzyFind *app.FuzzyFind 21 | issues []jira.Issue 22 | jql string 23 | } 24 | 25 | func NewJqlSearchView(api jira.Api) app.View { 26 | return &jqlSearchView{ 27 | api: api, 28 | jql: DefaultJqlQuery, 29 | } 30 | } 31 | 32 | func (view *jqlSearchView) Init() { 33 | go view.startJqlFuzzyFind() 34 | } 35 | 36 | func (view *jqlSearchView) Destroy() { 37 | // do nothing 38 | } 39 | 40 | func (view *jqlSearchView) Draw(screen tcell.Screen) { 41 | if view.fuzzyFind != nil { 42 | view.fuzzyFind.Draw(screen) 43 | } 44 | } 45 | 46 | func (view *jqlSearchView) Update() { 47 | if view.fuzzyFind != nil { 48 | view.fuzzyFind.Update() 49 | } 50 | } 51 | 52 | func (view *jqlSearchView) Resize(screenX, screenY int) { 53 | if view.fuzzyFind != nil { 54 | view.fuzzyFind.Resize(screenX, screenY) 55 | } 56 | } 57 | 58 | func (view *jqlSearchView) HandleKeyEvent(ev *tcell.EventKey) { 59 | if view.fuzzyFind != nil { 60 | view.fuzzyFind.HandleKeyEvent(ev) 61 | } 62 | } 63 | 64 | func (view *jqlSearchView) startJqlFuzzyFind() { 65 | app.GetApp().ClearNow() 66 | app.GetApp().Loading(true) 67 | view.fuzzyFind = app.NewFuzzyFindWithProvider(ui.MessageJqlFuzzyFind, view.findIssues) 68 | view.fuzzyFind.MarginBottom = 0 69 | view.fuzzyFind.SetQuery(DefaultJqlQuery) 70 | view.fuzzyFind.AlwaysShowAllResults() 71 | // higher debounce in order to give more time to change jql 72 | view.fuzzyFind.SetDebounceMs(500 * time.Millisecond) 73 | app.GetApp().Loading(false) 74 | if chosen := <-view.fuzzyFind.Complete; true { 75 | app.GetApp().ClearNow() 76 | query := view.fuzzyFind.GetQuery() 77 | if chosen.Index < 0 && strings.TrimSpace(query) == "" { 78 | // do nothing 79 | return 80 | } 81 | if chosen.Index >= 0 { 82 | chosenIssue := view.issues[chosen.Index] 83 | app.GoTo("issue", chosenIssue.Key, view.reopen, view.api) 84 | return 85 | } 86 | } 87 | } 88 | 89 | func (view *jqlSearchView) reopen() { 90 | app.GoTo("jql", view.api) 91 | } 92 | 93 | func (view *jqlSearchView) findIssues(query string) []string { 94 | app.GetApp().LoadingWithText(true, ui.MessageSearchIssuesLoading) 95 | issues, err := view.api.SearchJql(query) 96 | app.GetApp().Loading(false) 97 | if err != nil && strings.Contains(err.Error(), BadRequest) { 98 | // do nothing, invalid JQL query 99 | return FormatJiraIssues(view.issues) 100 | } 101 | if err != nil { 102 | app.Error(err.Error()) 103 | return FormatJiraIssues(view.issues) 104 | } 105 | view.issues = issues 106 | return FormatJiraIssues(view.issues) 107 | } 108 | -------------------------------------------------------------------------------- /cmd/fjira-cli/commands/root.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "github.com/mk-5/fjira/internal/fjira" 7 | "github.com/mk-5/fjira/internal/workspaces" 8 | "github.com/spf13/cobra" 9 | "regexp" 10 | ) 11 | 12 | type CtxVarWorkspaceSettings string 13 | 14 | const ( 15 | CtxWorkspaceSettings CtxVarWorkspaceSettings = "workspace-settings" 16 | ) 17 | 18 | var InvalidIssueKeyFormatErr = errors.New("invalid issue key format") 19 | 20 | // shouldSkipWorkspaceInitialization determines if a command should skip workspace initialization. 21 | func shouldSkipWorkspaceInitialization(cmd *cobra.Command) bool { 22 | cmdName := cmd.Name() 23 | 24 | // Skip for utility commands 25 | if cmdName == "version" || cmdName == "help" || cmdName == "completion" { 26 | return true 27 | } 28 | 29 | // Skip for completion subcommands 30 | if cmd.Parent() != nil && cmd.Parent().Name() == "completion" { 31 | return true 32 | } 33 | 34 | // Skip for shell completion commands 35 | shellCompletionCommands := []string{"bash", "zsh", "fish", "powershell"} 36 | for _, shellCmd := range shellCompletionCommands { 37 | if cmdName == shellCmd { 38 | return true 39 | } 40 | } 41 | 42 | return false 43 | } 44 | 45 | func GetRootCmd() *cobra.Command { 46 | cmd := &cobra.Command{ 47 | Use: "fjira", 48 | Short: "A fuzzy jira tui application", 49 | Long: `Fjira is a powerful terminal user interface (TUI) application designed to streamline your Jira workflow. 50 | With its fuzzy-find capabilities, it simplifies the process of searching and accessing Jira issues, 51 | making it easier than ever to locate and manage your tasks and projects efficiently. 52 | Say goodbye to manual searching and hello to increased productivity with fjira.`, 53 | Args: cobra.MaximumNArgs(2), 54 | PersistentPreRunE: func(cmd *cobra.Command, args []string) error { 55 | if shouldSkipWorkspaceInitialization(cmd) { 56 | return nil 57 | } 58 | // it's initializing fjira before every command 59 | s, err := fjira.Install("") 60 | if err != nil { 61 | return err 62 | } 63 | cmd.SetContext(context.WithValue(cmd.Context(), CtxWorkspaceSettings, s)) 64 | return nil 65 | }, 66 | RunE: func(cmd *cobra.Command, args []string) error { 67 | // run Issue command if issueKey provided via cli argument 68 | if len(args) == 1 { 69 | issueRegExp := regexp.MustCompile("^[A-Za-z0-9]{2,10}-[0-9]+$") 70 | issueKey := args[0] 71 | if !issueRegExp.MatchString(issueKey) { 72 | return InvalidIssueKeyFormatErr 73 | } 74 | issueCmd := GetIssueCmd() 75 | issueCmd.SetArgs([]string{issueKey}) 76 | return issueCmd.ExecuteContext(cmd.Context()) 77 | } 78 | projectKey, _ := cmd.Flags().GetString("project") 79 | s := cmd.Context().Value(CtxWorkspaceSettings).(*workspaces.WorkspaceSettings) 80 | f := fjira.CreateNewFjira(s) 81 | defer f.Close() 82 | f.Run(&fjira.CliArgs{ 83 | ProjectId: projectKey, 84 | }) 85 | return nil 86 | }, 87 | } 88 | cmd.AddCommand(&cobra.Command{Use: "", Short: "Open a fuzzy finder for projects as a default action"}) 89 | cmd.Flags().StringP("project", "p", "", "Open a project directly from CLI") 90 | return cmd 91 | } 92 | -------------------------------------------------------------------------------- /internal/jira/jira_projects_test.go: -------------------------------------------------------------------------------- 1 | package jira 2 | 3 | import ( 4 | "net/http" 5 | "reflect" 6 | "testing" 7 | ) 8 | 9 | func Test_httpJiraApi_FindProjects(t *testing.T) { 10 | tests := []struct { 11 | name string 12 | want []Project 13 | wantErr bool 14 | }{ 15 | {"should find projects without error", 16 | []Project{{"1", "FJIRA", "FJIR"}, {"2", "General", "GEN"}}, 17 | false, 18 | }, 19 | } 20 | for _, tt := range tests { 21 | t.Run(tt.name, func(t *testing.T) { 22 | api := NewJiraApiMock(func(w http.ResponseWriter, r *http.Request) { 23 | w.WriteHeader(200) 24 | body := ` 25 | { 26 | "values": [ 27 | { 28 | "expand": "description,lead,issueTypes,url,projectKeys,permissions,insight", 29 | "id": "1", 30 | "key": "FJIR", 31 | "name": "FJIRA", 32 | "projectTypeKey": "software", 33 | "simplified": true, 34 | "style": "next-gen", 35 | "isPrivate": false, 36 | "properties": {}, 37 | "entityId": "250cd492-c831-44d9-ae5c-17bd93922fa6", 38 | "uuid": "250cd492-c831-44d9-ae5c-17bd93922fa6" 39 | }, 40 | { 41 | "expand": "description,lead,issueTypes,url,projectKeys,permissions,insight", 42 | "id": "2", 43 | "key": "GEN", 44 | "name": "General", 45 | "projectTypeKey": "software", 46 | "simplified": false, 47 | "style": "classic", 48 | "isPrivate": false, 49 | "properties": {} 50 | } 51 | ] 52 | } 53 | ` 54 | w.Write([]byte(body)) //nolint:errcheck 55 | }) 56 | got, err := api.FindProjects() 57 | if (err != nil) != tt.wantErr { 58 | t.Errorf("FindProjects() error = %v, wantErr %v", err, tt.wantErr) 59 | return 60 | } 61 | if !reflect.DeepEqual(got, tt.want) { 62 | t.Errorf("FindProjects() got = %v, want %v", got, tt.want) 63 | } 64 | }) 65 | } 66 | } 67 | 68 | func Test_httpJiraApi_FindProject(t *testing.T) { 69 | tests := []struct { 70 | name string 71 | want *Project 72 | wantErr bool 73 | }{ 74 | {"should find project without error", 75 | &Project{"1", "FJIRA", "FJIR"}, 76 | false, 77 | }, 78 | } 79 | for _, tt := range tests { 80 | t.Run(tt.name, func(t *testing.T) { 81 | api := NewJiraApiMock(func(w http.ResponseWriter, r *http.Request) { 82 | w.WriteHeader(200) 83 | body := ` 84 | { 85 | "expand": "description,lead,issueTypes,url,projectKeys,permissions,insight", 86 | "id": "1", 87 | "key": "FJIR", 88 | "name": "FJIRA", 89 | "projectTypeKey": "software", 90 | "simplified": true, 91 | "style": "next-gen", 92 | "isPrivate": false, 93 | "properties": {}, 94 | "entityId": "250cd492-c831-44d9-ae5c-17bd93922fa6", 95 | "uuid": "250cd492-c831-44d9-ae5c-17bd93922fa6" 96 | } 97 | ` 98 | w.Write([]byte(body)) //nolint:errcheck 99 | }) 100 | got, err := api.FindProject("FJIR") 101 | if (err != nil) != tt.wantErr { 102 | t.Errorf("FindProjects() error = %v, wantErr %v", err, tt.wantErr) 103 | return 104 | } 105 | if !reflect.DeepEqual(got, tt.want) { 106 | t.Errorf("FindProjects() got = %v, want %v", got, tt.want) 107 | } 108 | }) 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /internal/labels/add_label.go: -------------------------------------------------------------------------------- 1 | package labels 2 | 3 | import ( 4 | "fmt" 5 | "github.com/gdamore/tcell/v2" 6 | "github.com/mk-5/fjira/internal/app" 7 | "github.com/mk-5/fjira/internal/jira" 8 | "github.com/mk-5/fjira/internal/ui" 9 | ) 10 | 11 | type addLabelView struct { 12 | app.View 13 | api jira.Api 14 | bottomBar *app.ActionBar 15 | topBar *app.ActionBar 16 | fuzzyFind *app.FuzzyFind 17 | issue *jira.Issue 18 | goBackFn func() 19 | labels []string 20 | } 21 | 22 | func NewAddLabelView(issue *jira.Issue, goBackFn func(), api jira.Api) app.View { 23 | return &addLabelView{ 24 | api: api, 25 | issue: issue, 26 | goBackFn: goBackFn, 27 | topBar: ui.CreateIssueTopBar(issue), 28 | bottomBar: ui.CreateBottomLeftBar(), 29 | } 30 | } 31 | 32 | func (view *addLabelView) Init() { 33 | go view.startLabelSearching() 34 | } 35 | 36 | func (*addLabelView) Destroy() { 37 | } 38 | 39 | func (view *addLabelView) Draw(screen tcell.Screen) { 40 | if view.fuzzyFind != nil { 41 | view.fuzzyFind.Draw(screen) 42 | } 43 | view.topBar.Draw(screen) 44 | view.bottomBar.Draw(screen) 45 | } 46 | 47 | func (view *addLabelView) Update() { 48 | view.bottomBar.Update() 49 | if view.fuzzyFind != nil { 50 | view.fuzzyFind.Update() 51 | } 52 | } 53 | 54 | func (view *addLabelView) Resize(screenX, screenY int) { 55 | if view.fuzzyFind != nil { 56 | view.fuzzyFind.Resize(screenX, screenY) 57 | } 58 | view.topBar.Resize(screenX, screenY) 59 | view.bottomBar.Resize(screenX, screenY) 60 | } 61 | 62 | func (view *addLabelView) HandleKeyEvent(ev *tcell.EventKey) { 63 | if view.fuzzyFind != nil { 64 | view.fuzzyFind.HandleKeyEvent(ev) 65 | } 66 | } 67 | 68 | func (view *addLabelView) startLabelSearching() { 69 | app.GetApp().ClearNow() 70 | app.GetApp().Loading(true) 71 | view.fuzzyFind = app.NewFuzzyFindWithProvider(ui.MessageLabelFuzzyFind, view.findLabels) 72 | view.fuzzyFind.MarginBottom = 0 73 | app.GetApp().Loading(false) 74 | if match := <-view.fuzzyFind.Complete; true { 75 | app.GetApp().ClearNow() 76 | label := view.fuzzyFind.GetQuery() 77 | if match.Index >= 0 { 78 | label = view.labels[match.Index] 79 | } 80 | view.fuzzyFind = nil 81 | view.addLabelToIssue(view.issue, label) 82 | } 83 | } 84 | 85 | func (view *addLabelView) findLabels(query string) []string { 86 | app.GetApp().LoadingWithText(true, ui.MessageSearchLabelsLoading) 87 | labels, err := view.api.FindLabels(view.issue, query) 88 | if err != nil { 89 | app.Error(err.Error()) 90 | } 91 | app.GetApp().Loading(false) 92 | view.labels = labels 93 | return labels 94 | } 95 | 96 | func (view *addLabelView) addLabelToIssue(issue *jira.Issue, label string) { 97 | if label == "" { 98 | view.goBackFn() 99 | return 100 | } 101 | view.doAddLabel(issue, label) 102 | view.goBackFn() 103 | } 104 | 105 | func (view *addLabelView) doAddLabel(issue *jira.Issue, label string) { 106 | app.GetApp().LoadingWithText(true, ui.MessageAddingLabel) 107 | err := view.api.AddLabel(issue.Key, label) 108 | app.GetApp().Loading(false) 109 | if err != nil { 110 | app.Error(fmt.Sprintf(ui.MessageCannotAddLabel, label, issue.Key, err)) 111 | return 112 | } 113 | app.Success(fmt.Sprintf(ui.MessageAddLabelSuccess, label, issue.Key)) 114 | } 115 | -------------------------------------------------------------------------------- /internal/fjira/fjira_test.go: -------------------------------------------------------------------------------- 1 | package fjira 2 | 3 | import ( 4 | "github.com/gdamore/tcell/v2" 5 | "github.com/mk-5/fjira/internal/app" 6 | "github.com/mk-5/fjira/internal/jira" 7 | os2 "github.com/mk-5/fjira/internal/os" 8 | "github.com/mk-5/fjira/internal/workspaces" 9 | "github.com/stretchr/testify/assert" 10 | "net/http" 11 | "testing" 12 | "time" 13 | ) 14 | 15 | func TestFjira_bootstrap(t *testing.T) { 16 | screen := tcell.NewSimulationScreen("utf-8") 17 | _ = screen.Init() //nolint:errcheck 18 | defer screen.Fini() 19 | 20 | type args struct { 21 | cliArgs CliArgs 22 | viewPredicate func() bool 23 | } 24 | tests := []struct { 25 | name string 26 | args args 27 | }{ 28 | {"should switch to workspace view", args{ 29 | cliArgs: CliArgs{WorkspaceSwitch: true}, 30 | viewPredicate: func() bool { 31 | return app.CurrentScreenName() == "workspaces-switch" 32 | }, 33 | }}, 34 | {"should switch to project view", args{ 35 | cliArgs: CliArgs{ProjectId: "test"}, 36 | viewPredicate: func() bool { 37 | return app.CurrentScreenName() == "issues-search" 38 | }, 39 | }}, 40 | {"should switch to issue view", args{ 41 | cliArgs: CliArgs{IssueKey: "test"}, 42 | viewPredicate: func() bool { 43 | return app.CurrentScreenName() == "issue" 44 | }, 45 | }}, 46 | {"should switch to jql view", args{ 47 | cliArgs: CliArgs{JqlMode: true}, 48 | viewPredicate: func() bool { 49 | return app.CurrentScreenName() == "jql" 50 | }, 51 | }}, 52 | {"should switch to filters view", args{ 53 | cliArgs: CliArgs{FiltersMode: true}, 54 | viewPredicate: func() bool { 55 | return app.CurrentScreenName() == "filters" 56 | }, 57 | }}, 58 | {"should switch to projects search by default", args{ 59 | cliArgs: CliArgs{}, 60 | viewPredicate: func() bool { 61 | <-time.After(500 * time.Millisecond) 62 | return app.CurrentScreenName() == "projects" 63 | }, 64 | }}, 65 | } 66 | for _, tt := range tests { 67 | t.Run(tt.name, func(t *testing.T) { 68 | // given 69 | tempDir := t.TempDir() 70 | _ = os2.SetUserHomeDir(tempDir) 71 | a := app.CreateNewAppWithScreen(screen) 72 | api := jira.NewJiraApiMock(func(w http.ResponseWriter, r *http.Request) { 73 | w.WriteHeader(200) 74 | w.Write([]byte("{}")) //nolint:errcheck 75 | }) 76 | fjira := CreateNewFjira(&workspaces.WorkspaceSettings{}) 77 | fjira.registerGoTos() 78 | fjira.app = a 79 | fjira.api = api 80 | go a.Start() 81 | 82 | // when 83 | go fjira.bootstrap(&tt.args.cliArgs) 84 | for app.CurrentScreenName() == "" { 85 | <-time.After(10 * time.Millisecond) 86 | } 87 | <-time.After(250 * time.Millisecond) 88 | 89 | // then 90 | ok := tt.args.viewPredicate() 91 | assert.New(t).True(ok, "Current view is invalid: ", app.GetApp().CurrentView(), app.CurrentScreenName()) 92 | }) 93 | } 94 | } 95 | 96 | func TestFjira_run_should_run_without_error(t *testing.T) { 97 | // given 98 | screen := tcell.NewSimulationScreen("utf-8") 99 | _ = screen.Init() //nolint:errcheck 100 | defer screen.Fini() 101 | app.CreateNewAppWithScreen(screen) 102 | fjira := CreateNewFjira(&workspaces.WorkspaceSettings{}) 103 | 104 | // when 105 | go fjira.Run(&CliArgs{}) 106 | <-time.After(300 * time.Millisecond) 107 | 108 | // then 109 | assert.False(t, app.GetApp().IsQuit()) 110 | } 111 | -------------------------------------------------------------------------------- /internal/labels/add_label_test.go: -------------------------------------------------------------------------------- 1 | package labels 2 | 3 | import ( 4 | "bytes" 5 | "github.com/gdamore/tcell/v2" 6 | "github.com/mk-5/fjira/internal/app" 7 | "github.com/mk-5/fjira/internal/jira" 8 | "github.com/stretchr/testify/assert" 9 | "net/http" 10 | "testing" 11 | "time" 12 | ) 13 | 14 | func TestNewAddLabelView(t *testing.T) { 15 | screen := tcell.NewSimulationScreen("utf-8") 16 | _ = screen.Init() 17 | defer screen.Fini() 18 | 19 | type args struct { 20 | issue *jira.Issue 21 | } 22 | tests := []struct { 23 | name string 24 | args args 25 | }{ 26 | {"should initialize & draw add label view", args{issue: &jira.Issue{Key: "ABC-123"}}}, 27 | } 28 | for _, tt := range tests { 29 | t.Run(tt.name, func(t *testing.T) { 30 | // given 31 | app.InitTestApp(screen) 32 | api := jira.NewJiraApiMock(func(w http.ResponseWriter, r *http.Request) { 33 | w.WriteHeader(200) 34 | body := ` 35 | { 36 | "token": "", 37 | "suggestions": [ 38 | { 39 | "label": "TestLabel1", 40 | "html": "TestLabel1" 41 | }, 42 | { 43 | "label": "TestLabel2", 44 | "html": "TestLabel2" 45 | } 46 | ] 47 | } 48 | ` 49 | _, _ = w.Write([]byte(body)) 50 | }) 51 | view := NewAddLabelView(tt.args.issue, func() {}, api).(*addLabelView) 52 | 53 | // when 54 | view.Init() 55 | for view.fuzzyFind == nil { 56 | <-time.After(10 * time.Millisecond) 57 | } 58 | <-time.After(500 * time.Millisecond) 59 | query := "label1" 60 | for _, key := range query { 61 | view.HandleKeyEvent(tcell.NewEventKey(-1, key, tcell.ModNone)) 62 | } 63 | i := 0 // keep app going for a while 64 | view.Resize(screen.Size()) 65 | for { 66 | view.Update() 67 | view.Draw(screen) 68 | i++ 69 | if i > 100000 { 70 | break 71 | } 72 | } 73 | 74 | var buffer bytes.Buffer 75 | contents, x, y := screen.GetContents() 76 | screen.Show() 77 | for i := 0; i < x*y; i++ { 78 | buffer.Write(contents[i].Bytes) 79 | } 80 | result := buffer.String() 81 | 82 | // then 83 | assert.Contains(t, result, "TestLabel1") 84 | assert.NotContains(t, result, "TestLabel2") 85 | }) 86 | } 87 | } 88 | 89 | func Test_fjiraAddLabelView_doAddLabel(t *testing.T) { 90 | screen := tcell.NewSimulationScreen("utf-8") 91 | _ = screen.Init() //nolint:errcheck 92 | defer screen.Fini() 93 | 94 | type args struct { 95 | issue *jira.Issue 96 | label string 97 | } 98 | 99 | tests := []struct { 100 | name string 101 | args args 102 | }{ 103 | {"should send add label request", args{issue: &jira.Issue{Key: "ABC", Id: "123"}, label: "testlabel1"}}, 104 | } 105 | for _, tt := range tests { 106 | t.Run(tt.name, func(t *testing.T) { 107 | // given 108 | app.InitTestApp(screen) 109 | addLabelRequestSent := make(chan bool) 110 | api := jira.NewJiraApiMock(func(w http.ResponseWriter, r *http.Request) { 111 | w.WriteHeader(200) 112 | _, _ = w.Write([]byte(``)) 113 | 114 | assert.Contains(t, r.RequestURI, tt.args.issue.Key) 115 | addLabelRequestSent <- true 116 | }) 117 | view := NewAddLabelView(tt.args.issue, func() {}, api).(*addLabelView) 118 | 119 | // when 120 | go view.doAddLabel(tt.args.issue, tt.args.label) 121 | 122 | // then 123 | select { 124 | case <-addLabelRequestSent: 125 | case <-time.After(3 * time.Second): 126 | t.Fail() 127 | } 128 | }) 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /internal/app/fuzzy_find_test.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "bytes" 5 | "github.com/gdamore/tcell/v2" 6 | "github.com/stretchr/testify/assert" 7 | "strings" 8 | "testing" 9 | ) 10 | 11 | func TestFuzzyFind_Draw(t *testing.T) { 12 | screen := tcell.NewSimulationScreen("utf-8") 13 | _ = screen.Init() //nolint:errcheck 14 | defer screen.Fini() 15 | 16 | type args struct { 17 | records []string 18 | query string 19 | } 20 | tests := []struct { 21 | name string 22 | args args 23 | want string 24 | }{ 25 | {"should show valid results", args{records: []string{"abc"}, query: "abc"}, "abc"}, 26 | {"should show valid results", args{records: []string{"Brzęczyszczykiewicz"}, query: "c"}, "Brzęczyszczykiewicz"}, 27 | } 28 | for _, tt := range tests { 29 | t.Run(tt.name, func(t *testing.T) { 30 | // given 31 | fuzzyFind := NewFuzzyFind("test", tt.args.records) 32 | 33 | // when 34 | for _, key := range tt.args.query { 35 | fuzzyFind.HandleKeyEvent(tcell.NewEventKey(-1, key, tcell.ModNone)) 36 | } 37 | fuzzyFind.Update() 38 | fuzzyFind.Resize(screen.Size()) 39 | fuzzyFind.Draw(screen) 40 | var buffer bytes.Buffer 41 | contents, x, y := screen.GetContents() 42 | screen.Show() 43 | for i := 0; i < x*y; i++ { 44 | if string(contents[i].Bytes) != "" { 45 | buffer.Write(contents[i].Bytes) 46 | } 47 | } 48 | result := strings.TrimSpace(buffer.String()) 49 | 50 | // then 51 | assert.NotEmpty(t, fuzzyFind.GetQuery()) 52 | assert.Contains(t, result, tt.want) 53 | }) 54 | } 55 | } 56 | 57 | func TestFuzzyFind_HandleKeyEvent(t *testing.T) { 58 | screen := tcell.NewSimulationScreen("utf-8") 59 | _ = screen.Init() //nolint:errcheck 60 | CreateNewAppWithScreen(screen) 61 | 62 | type args struct { 63 | ev []*tcell.EventKey 64 | expectedSelection int 65 | } 66 | tests := []struct { 67 | name string 68 | args args 69 | }{ 70 | {"should go up, and go down", args{ev: []*tcell.EventKey{ 71 | tcell.NewEventKey(tcell.KeyUp, 0, tcell.ModNone), 72 | tcell.NewEventKey(tcell.KeyUp, 0, tcell.ModNone), 73 | tcell.NewEventKey(tcell.KeyUp, 0, tcell.ModNone), 74 | tcell.NewEventKey(tcell.KeyDown, 0, tcell.ModNone), 75 | }, expectedSelection: 2}}, 76 | {"should go up, and go down using tab/tab-shift", args{ev: []*tcell.EventKey{ 77 | tcell.NewEventKey(tcell.KeyTab, 0, tcell.ModNone), 78 | tcell.NewEventKey(tcell.KeyTab, 0, tcell.ModNone), 79 | tcell.NewEventKey(tcell.KeyTab, 0, tcell.ModNone), 80 | tcell.NewEventKey(tcell.KeyBacktab, 0, tcell.ModNone), 81 | }, expectedSelection: 2}}, 82 | {"should go up with page up", args{ev: []*tcell.EventKey{ 83 | tcell.NewEventKey(tcell.KeyPgUp, 0, tcell.ModNone), 84 | }, expectedSelection: 3}}, 85 | {"should go down with page down", args{ev: []*tcell.EventKey{ 86 | tcell.NewEventKey(tcell.KeyPgUp, 0, tcell.ModNone), 87 | tcell.NewEventKey(tcell.KeyPgDn, 0, tcell.ModNone), 88 | }, expectedSelection: 0}}, 89 | } 90 | for _, tt := range tests { 91 | t.Run(tt.name, func(t *testing.T) { 92 | // given 93 | fuzzyFind := NewFuzzyFind("test", []string{"test1", "test2", "test3", "test4"}) 94 | fuzzyFind.HandleKeyEvent(tcell.NewEventKey(0, 't', tcell.ModNone)) 95 | fuzzyFind.Update() 96 | 97 | // when 98 | for _, key := range tt.args.ev { 99 | fuzzyFind.HandleKeyEvent(key) 100 | } 101 | 102 | // then 103 | assert.Equal(t, tt.args.expectedSelection, fuzzyFind.selected) 104 | }) 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /internal/users/user_assign.go: -------------------------------------------------------------------------------- 1 | package users 2 | 3 | import ( 4 | "fmt" 5 | "github.com/gdamore/tcell/v2" 6 | "github.com/mk-5/fjira/internal/app" 7 | "github.com/mk-5/fjira/internal/jira" 8 | "github.com/mk-5/fjira/internal/ui" 9 | ) 10 | 11 | type userAssignChangeView struct { 12 | app.View 13 | api jira.Api 14 | bottomBar *app.ActionBar 15 | topBar *app.ActionBar 16 | fuzzyFind *app.FuzzyFind 17 | issue *jira.Issue 18 | goBackFn func() 19 | } 20 | 21 | func NewAssignChangeView(issue *jira.Issue, goBackFn func(), api jira.Api) app.View { 22 | return &userAssignChangeView{ 23 | api: api, 24 | issue: issue, 25 | topBar: ui.CreateIssueTopBar(issue), 26 | bottomBar: ui.CreateBottomLeftBar(), 27 | goBackFn: goBackFn, 28 | } 29 | } 30 | 31 | func (view *userAssignChangeView) Init() { 32 | go view.startUsersSearching() 33 | } 34 | 35 | func (view *userAssignChangeView) Destroy() { 36 | // do nothing 37 | } 38 | 39 | func (view *userAssignChangeView) Draw(screen tcell.Screen) { 40 | if view.fuzzyFind != nil { 41 | view.fuzzyFind.Draw(screen) 42 | } 43 | view.topBar.Draw(screen) 44 | view.bottomBar.Draw(screen) 45 | } 46 | 47 | func (view *userAssignChangeView) Update() { 48 | view.bottomBar.Update() 49 | if view.fuzzyFind != nil { 50 | view.fuzzyFind.Update() 51 | } 52 | } 53 | 54 | func (view *userAssignChangeView) Resize(screenX, screenY int) { 55 | if view.fuzzyFind != nil { 56 | view.fuzzyFind.Resize(screenX, screenY) 57 | } 58 | view.topBar.Resize(screenX, screenY) 59 | view.bottomBar.Resize(screenX, screenY) 60 | } 61 | 62 | func (view *userAssignChangeView) HandleKeyEvent(ev *tcell.EventKey) { 63 | if view.fuzzyFind != nil { 64 | view.fuzzyFind.HandleKeyEvent(ev) 65 | } 66 | } 67 | 68 | func (view *userAssignChangeView) startUsersSearching() { 69 | app.GetApp().ClearNow() 70 | app.GetApp().Loading(true) 71 | var us *[]jira.User 72 | view.fuzzyFind, us = NewFuzzyFind(view.issue.Fields.Project.Key, view.api) 73 | view.fuzzyFind.MarginBottom = 0 74 | app.GetApp().Loading(false) 75 | if user := <-view.fuzzyFind.Complete; true { 76 | app.GetApp().ClearNow() 77 | if user.Index < 0 { 78 | if view.goBackFn != nil { 79 | view.goBackFn() 80 | } 81 | return 82 | } 83 | view.fuzzyFind = nil 84 | view.assignUserToTicket(view.issue, &(*us)[user.Index]) 85 | } 86 | } 87 | 88 | func (view *userAssignChangeView) assignUserToTicket(issue *jira.Issue, user *jira.User) { 89 | if user == nil { 90 | view.goBackFn() 91 | return 92 | } 93 | message := fmt.Sprintf(ui.MessageChangingAssigneeTo, issue.Key, user.DisplayName) 94 | app.GetApp().ClearNow() 95 | view.bottomBar.AddItem(ui.NewYesBarItem()) 96 | view.bottomBar.AddItem(ui.NewCancelBarItem()) 97 | // TODO - should confirm be also drawable? at the moment yes/no are rendered out of the confirm thingy.. 98 | userAssign := app.Confirm(app.GetApp(), message) 99 | if userAssign { 100 | view.doAssignmentChange(issue, user) 101 | } 102 | if view.goBackFn != nil { 103 | view.goBackFn() 104 | } 105 | } 106 | 107 | func (view *userAssignChangeView) doAssignmentChange(issue *jira.Issue, user *jira.User) { 108 | app.GetApp().LoadingWithText(true, ui.MessageAssigningUser) 109 | err := view.api.DoAssignee(issue.Key, user) 110 | app.GetApp().Loading(false) 111 | if err != nil { 112 | app.Error(fmt.Sprintf(ui.MessageCannotAssignUser, user.DisplayName, issue.Key, err.Error(), user.AccountId)) 113 | return 114 | } 115 | app.Success(fmt.Sprintf(ui.MessageAssignSuccess, user.DisplayName, issue.Key)) 116 | } 117 | -------------------------------------------------------------------------------- /internal/statuses/status_change.go: -------------------------------------------------------------------------------- 1 | package statuses 2 | 3 | import ( 4 | "fmt" 5 | "github.com/gdamore/tcell/v2" 6 | "github.com/mk-5/fjira/internal/app" 7 | "github.com/mk-5/fjira/internal/jira" 8 | "github.com/mk-5/fjira/internal/ui" 9 | ) 10 | 11 | type statusChangeView struct { 12 | app.View 13 | api jira.Api 14 | topBar *app.ActionBar 15 | bottomBar *app.ActionBar 16 | fuzzyFind *app.FuzzyFind 17 | issue *jira.Issue 18 | goBackFn func() 19 | } 20 | 21 | func NewStatusChangeView(issue *jira.Issue, goBackFn func(), api jira.Api) app.View { 22 | return &statusChangeView{ 23 | api: api, 24 | goBackFn: goBackFn, 25 | issue: issue, 26 | topBar: ui.CreateIssueTopBar(issue), 27 | bottomBar: ui.CreateBottomLeftBar(), 28 | } 29 | } 30 | 31 | func (view *statusChangeView) Init() { 32 | go view.startStatusSearching() 33 | } 34 | 35 | func (view *statusChangeView) Destroy() { 36 | // do nothing 37 | } 38 | 39 | func (view *statusChangeView) Draw(screen tcell.Screen) { 40 | if view.fuzzyFind != nil { 41 | view.fuzzyFind.Draw(screen) 42 | } 43 | view.topBar.Draw(screen) 44 | view.bottomBar.Draw(screen) 45 | } 46 | 47 | func (view *statusChangeView) Update() { 48 | view.topBar.Update() 49 | view.bottomBar.Update() 50 | if view.fuzzyFind != nil { 51 | view.fuzzyFind.Update() 52 | } 53 | } 54 | 55 | func (view *statusChangeView) Resize(screenX, screenY int) { 56 | if view.fuzzyFind != nil { 57 | view.fuzzyFind.Resize(screenX, screenY) 58 | } 59 | view.topBar.Resize(screenX, screenY) 60 | view.bottomBar.Resize(screenX, screenY) 61 | } 62 | 63 | func (view *statusChangeView) HandleKeyEvent(ev *tcell.EventKey) { 64 | if view.fuzzyFind != nil { 65 | view.fuzzyFind.HandleKeyEvent(ev) 66 | } 67 | } 68 | 69 | func (view *statusChangeView) startStatusSearching() { 70 | app.GetApp().ClearNow() 71 | app.GetApp().Loading(true) 72 | statuses := view.transitions(view.issue.Id) 73 | statusesStrings := FormatJiraTransitions(statuses) 74 | view.fuzzyFind = app.NewFuzzyFind(ui.MessageStatusFuzzyFind, statusesStrings) 75 | view.fuzzyFind.MarginBottom = 0 76 | app.GetApp().Loading(false) 77 | if status := <-view.fuzzyFind.Complete; true { 78 | app.GetApp().ClearNow() 79 | if status.Index < 0 { 80 | if view.goBackFn != nil { 81 | view.goBackFn() 82 | } 83 | return 84 | } 85 | view.fuzzyFind = nil 86 | view.changeStatusTo(&statuses[status.Index]) 87 | } 88 | } 89 | 90 | func (view *statusChangeView) changeStatusTo(status *jira.IssueTransition) { 91 | message := fmt.Sprintf(ui.MessageChangingStatusTo, view.issue.Key, status.Name) 92 | app.GetApp().ClearNow() 93 | //view.bottomBar.AddItem(NewNewStatusBarItem(status.Name)) 94 | view.bottomBar.AddItem(ui.NewYesBarItem()) 95 | view.bottomBar.AddItem(ui.NewCancelBarItem()) 96 | changeStatus := app.Confirm(app.GetApp(), message) 97 | if changeStatus { 98 | view.changeStatusForTicket(view.issue, status) 99 | } 100 | if view.goBackFn != nil { 101 | view.goBackFn() 102 | } 103 | } 104 | 105 | func (view *statusChangeView) transitions(issueId string) []jira.IssueTransition { 106 | transitions, _ := view.api.FindTransitions(issueId) 107 | return transitions 108 | } 109 | 110 | func (view *statusChangeView) changeStatusForTicket(issue *jira.Issue, status *jira.IssueTransition) { 111 | app.GetApp().ClearNow() 112 | app.GetApp().LoadingWithText(true, ui.MessageChangingStatus) 113 | err := view.api.DoTransition(issue.Key, status) 114 | app.GetApp().Loading(false) 115 | if err != nil { 116 | app.Error(err.Error()) 117 | return 118 | } 119 | app.Success(fmt.Sprintf(ui.MessageChangeStatusSuccess, issue.Key, status.Name)) 120 | } 121 | -------------------------------------------------------------------------------- /internal/fjira/fjira.go: -------------------------------------------------------------------------------- 1 | package fjira 2 | 3 | import ( 4 | "errors" 5 | "github.com/mk-5/fjira/internal/app" 6 | "github.com/mk-5/fjira/internal/boards" 7 | "github.com/mk-5/fjira/internal/filters" 8 | "github.com/mk-5/fjira/internal/issues" 9 | "github.com/mk-5/fjira/internal/jira" 10 | "github.com/mk-5/fjira/internal/labels" 11 | "github.com/mk-5/fjira/internal/projects" 12 | "github.com/mk-5/fjira/internal/statuses" 13 | "github.com/mk-5/fjira/internal/ui" 14 | "github.com/mk-5/fjira/internal/users" 15 | "github.com/mk-5/fjira/internal/workspaces" 16 | "strings" 17 | "sync" 18 | "time" 19 | ) 20 | 21 | const ( 22 | WelcomeMessage = ` 23 | ____ __________ ___ 24 | / __/ / / _/ __ \/ | 25 | / /___ / // // /_/ / /| | 26 | / __/ /_/ // // _, _/ ___ | 27 | /_/ \____/___/_/ |_/_/ |_| 28 | 29 | The command line tool for Jira. 30 | ` 31 | ) 32 | 33 | var InstallFailedErr = errors.New("cannot use fjira. Please check error logs in order to install missing packages") 34 | 35 | type Fjira struct { 36 | app *app.App 37 | api jira.Api 38 | jiraUrl string 39 | workspace string 40 | } 41 | 42 | // CliArgs TODO - drop it, and use cobra directly 43 | type CliArgs struct { 44 | ProjectId string 45 | IssueKey string 46 | Workspace string 47 | WorkspaceSwitch bool 48 | WorkspaceEdit bool 49 | JqlMode bool 50 | FiltersMode bool 51 | } 52 | 53 | var ( 54 | fjiraInstance *Fjira 55 | fjiraOnce sync.Once 56 | ) 57 | 58 | func CreateNewFjira(settings *workspaces.WorkspaceSettings) *Fjira { 59 | if settings == nil { 60 | panic("Cannot find appropriate fjira settings!") 61 | } 62 | fjiraOnce.Do(func() { 63 | url := strings.TrimSuffix(settings.JiraRestUrl, "/") 64 | api, err := jira.NewApi(url, settings.JiraUsername, settings.JiraToken, settings.JiraTokenType) 65 | if err != nil { 66 | app.Error(err.Error()) 67 | } 68 | fjiraInstance = &Fjira{ 69 | app: app.CreateNewApp(), 70 | api: api, 71 | jiraUrl: url, 72 | workspace: settings.Workspace, 73 | } 74 | }) 75 | return fjiraInstance 76 | } 77 | 78 | func (f *Fjira) Run(args *CliArgs) { 79 | x := app.ClampInt(f.app.ScreenX/2-18, 0, f.app.ScreenX) 80 | y := app.ClampInt(f.app.ScreenY/2-4, 0, f.app.ScreenY) 81 | welcomeText := app.NewText(x, y, app.DefaultStyle(), WelcomeMessage) 82 | f.app.AddDrawable(welcomeText) 83 | f.registerGoTos() 84 | go f.bootstrap(args) 85 | f.app.Start() 86 | } 87 | 88 | func (f *Fjira) Close() { 89 | f.api.Close() 90 | if f.app != nil { 91 | f.app.PanicRecover() 92 | } 93 | } 94 | 95 | func (f *Fjira) registerGoTos() { 96 | projects.RegisterGoto() 97 | issues.RegisterGoTo() 98 | users.RegisterGoTo() 99 | statuses.RegisterGoTo() 100 | labels.RegisterGoTo() 101 | workspaces.RegisterGoTo() 102 | boards.RegisterGoTo() 103 | ui.RegisterGoTo() 104 | filters.RegisterGoTo() 105 | } 106 | 107 | func (f *Fjira) bootstrap(args *CliArgs) { 108 | defer f.app.PanicRecover() 109 | if args.WorkspaceSwitch { 110 | app.GoTo("workspaces-switch") 111 | return 112 | } 113 | if args.ProjectId != "" { 114 | app.GoTo("issues-search", args.ProjectId, func() { 115 | app.GoTo("projects", f.api) 116 | }, f.api) 117 | return 118 | } 119 | if args.IssueKey != "" { 120 | app.GoTo("issue", args.IssueKey, nil, f.api) 121 | return 122 | } 123 | if args.JqlMode { 124 | app.GoTo("jql", f.api) 125 | return 126 | } 127 | if args.FiltersMode { 128 | app.GoTo("filters", f.api) 129 | return 130 | } 131 | time.Sleep(350 * time.Millisecond) 132 | f.app.RunOnAppRoutine(func() { 133 | app.GoTo("projects", f.api) 134 | }) 135 | } 136 | -------------------------------------------------------------------------------- /internal/app/app_test.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "github.com/gdamore/tcell/v2" 5 | "github.com/stretchr/testify/assert" 6 | "testing" 7 | "time" 8 | ) 9 | 10 | func TestApp(t *testing.T) { 11 | tests := []struct { 12 | name string 13 | }{ 14 | {"should create app without error"}, 15 | } 16 | for _, tt := range tests { 17 | t.Run(tt.name, func(t *testing.T) { 18 | screen := tcell.NewSimulationScreen("utf-8") 19 | _ = screen.Init() //nolint:errcheck 20 | 21 | // when 22 | a := &App{ 23 | screen: screen, 24 | spinnerIndex: 0, 25 | keyEvent: make(chan *tcell.EventKey), 26 | runOnAppRoutine: make([]func(), 0, 64), 27 | drawables: make([]Drawable, 0, 256), 28 | systems: make([]System, 0, 128), 29 | flash: make([]Drawable, 0, 5), 30 | keepAlive: make(map[interface{}]bool), 31 | dirty: true, 32 | } 33 | go a.Start() 34 | <-time.NewTimer(100 * time.Millisecond).C 35 | a.Quit() 36 | 37 | // then 38 | assert.True(t, a.quit) 39 | }) 40 | } 41 | } 42 | 43 | func TestApp_KeepAlive(t *testing.T) { 44 | screen := tcell.NewSimulationScreen("utf-8") 45 | _ = screen.Init() //nolint:errcheck 46 | defer screen.Fini() 47 | 48 | tests := []struct { 49 | name string 50 | }{ 51 | {"should mark drawables as keep-alive"}, 52 | } 53 | for _, tt := range tests { 54 | t.Run(tt.name, func(t *testing.T) { 55 | screen := tcell.NewSimulationScreen("utf-8") 56 | _ = screen.Init() //nolint:errcheck 57 | defer screen.Fini() 58 | 59 | // given 60 | a := &App{ 61 | screen: screen, 62 | spinnerIndex: 0, 63 | keyEvent: make(chan *tcell.EventKey), 64 | runOnAppRoutine: make([]func(), 0, 64), 65 | drawables: make([]Drawable, 0, 256), 66 | systems: make([]System, 0, 128), 67 | flash: make([]Drawable, 0, 5), 68 | keepAlive: make(map[interface{}]bool), 69 | dirty: true, 70 | } 71 | drawable := NewText(0, 0, tcell.StyleDefault, "test") 72 | a.AddDrawable(drawable) 73 | 74 | // when 75 | a.KeepAlive(drawable) 76 | 77 | // then 78 | assert.Equal(t, true, a.keepAlive[drawable]) 79 | 80 | // and then 81 | a.UnKeepAlive(drawable) 82 | 83 | // then 84 | assert.Equal(t, false, a.keepAlive[drawable]) 85 | }) 86 | } 87 | } 88 | 89 | func Test_App_processTerminalEvents(t *testing.T) { 90 | tests := []struct { 91 | name string 92 | keyEvent tcell.Key 93 | expectedQuit bool 94 | }{ 95 | {"should process terminal events and handle keys", tcell.KeyEnter, false}, 96 | {"should process terminal events and handle keys", tcell.KeyEscape, true}, 97 | } 98 | for _, tt := range tests { 99 | t.Run(tt.name, func(t *testing.T) { 100 | // given 101 | screen := tcell.NewSimulationScreen("utf-8") 102 | _ = screen.Init() //nolint:errcheck 103 | 104 | // when 105 | a := &App{ 106 | screen: screen, 107 | spinnerIndex: 0, 108 | keyEvent: make(chan *tcell.EventKey), 109 | runOnAppRoutine: make([]func(), 0, 64), 110 | drawables: make([]Drawable, 0, 256), 111 | systems: make([]System, 0, 128), 112 | flash: make([]Drawable, 0, 5), 113 | keepAlive: make(map[interface{}]bool), 114 | dirty: true, 115 | } 116 | go a.Start() 117 | <-time.NewTimer(100 * time.Millisecond).C 118 | go a.processTerminalEvents() 119 | <-time.NewTimer(100 * time.Millisecond).C 120 | screen.InjectKey(tt.keyEvent, 'a', tcell.ModNone) 121 | <-time.NewTimer(100 * time.Millisecond).C 122 | 123 | // then 124 | assert.Equal(t, tt.expectedQuit, a.quit) 125 | }) 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /internal/boards/goto_test.go: -------------------------------------------------------------------------------- 1 | package boards 2 | 3 | import ( 4 | "net/http" 5 | "testing" 6 | 7 | "github.com/mk-5/fjira/internal/app" 8 | "github.com/mk-5/fjira/internal/jira" 9 | assert2 "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestGoIntoBoardView(t *testing.T) { 13 | RegisterGoTo() 14 | app.InitTestApp(nil) 15 | 16 | type args struct { 17 | gotoMethod func() 18 | viewPredicate func() bool 19 | } 20 | tests := []struct { 21 | name string 22 | args args 23 | }{ 24 | {"should switch view into board view", args{ 25 | gotoMethod: func() { 26 | app.GoTo("boards", &jira.Project{}, &jira.BoardItem{Id: 1}, func() {}, jira.NewJiraApiMock(func(w http.ResponseWriter, r *http.Request) { 27 | _, _ = w.Write([]byte("{}")) 28 | })) 29 | }, 30 | viewPredicate: func() bool { 31 | _, ok := app.GetApp().CurrentView().(*boardView) 32 | return ok 33 | }, 34 | }}, 35 | } 36 | for _, tt := range tests { 37 | t.Run(tt.name, func(t *testing.T) { 38 | // when 39 | tt.args.gotoMethod() 40 | 41 | // then 42 | ok := tt.args.viewPredicate() 43 | assert2.New(t).True(ok, "Current view is invalid.") 44 | }) 45 | } 46 | } 47 | 48 | func TestGoIntoBoardView_WithSprints(t *testing.T) { 49 | RegisterGoTo() 50 | app.InitTestApp(nil) 51 | 52 | api := jira.NewJiraApiMock(func(w http.ResponseWriter, r *http.Request) { 53 | switch r.URL.Path { 54 | case "/rest/agile/1.0/board/1/configuration": 55 | // Scrum board configuration with filter id 56 | w.WriteHeader(200) 57 | _, _ = w.Write([]byte(`{ 58 | "id": 1, 59 | "type": "scrum", 60 | "filter": { "id": "10000" }, 61 | "columnConfig": { "columns": [] } 62 | }`)) 63 | case "/rest/api/2/filter/10000": 64 | // Filter with some JQL 65 | w.WriteHeader(200) 66 | _, _ = w.Write([]byte(`{ 67 | "id": "10000", 68 | "name": "Test Filter", 69 | "jql": "project = GEN", 70 | "favourite": true 71 | }`)) 72 | case "/rest/agile/1.0/board/1/sprint": 73 | // Ensure correct query param is passed for sprints 74 | if got := r.URL.Query().Get("state"); got != "active,future" { 75 | t.Fatalf("expected state query param 'active,future', got '%s'", got) 76 | } 77 | w.WriteHeader(200) 78 | _, _ = w.Write([]byte(`{ 79 | "maxResults": 50, 80 | "startAt": 0, 81 | "total": 2, 82 | "isLast": true, 83 | "values": [ 84 | { 85 | "id": 11, 86 | "self": "https://test.net/rest/agile/1.0/sprint/11", 87 | "state": "future", 88 | "name": "Sprint Future", 89 | "startDate": null, 90 | "endDate": null, 91 | "completeDate": null, 92 | "originBoardId": 1, 93 | "goal": "" 94 | }, 95 | { 96 | "id": 10, 97 | "self": "https://test.net/rest/agile/1.0/sprint/10", 98 | "state": "active", 99 | "name": "Sprint Active", 100 | "startDate": null, 101 | "endDate": null, 102 | "completeDate": null, 103 | "originBoardId": 1, 104 | "goal": "" 105 | } 106 | ] 107 | }`)) 108 | default: 109 | w.WriteHeader(200) 110 | _, _ = w.Write([]byte(`{}`)) 111 | } 112 | }) 113 | 114 | // when 115 | app.GoTo("boards", &jira.Project{}, &jira.BoardItem{Id: 1}, func() {}, api) 116 | 117 | // then 118 | view, ok := app.GetApp().CurrentView().(*boardView) 119 | if !ok { 120 | t.Fatalf("expected boardView to be current view") 121 | } 122 | assert := assert2.New(t) 123 | assert.NotNil(view.activeSprint, "active sprint should be set") 124 | assert.Equal("active", view.activeSprint.State, "active sprint should be selected") 125 | assert.Equal("Sprint Active", view.activeSprint.Name, "active sprint name mismatch") 126 | assert.True(len(view.sprints) >= 2, "sprints should be set on the view") 127 | } 128 | -------------------------------------------------------------------------------- /internal/statuses/status_change_test.go: -------------------------------------------------------------------------------- 1 | package statuses 2 | 3 | import ( 4 | "bytes" 5 | "github.com/gdamore/tcell/v2" 6 | "github.com/mk-5/fjira/internal/app" 7 | "github.com/mk-5/fjira/internal/jira" 8 | "github.com/stretchr/testify/assert" 9 | "net/http" 10 | "testing" 11 | "time" 12 | ) 13 | 14 | func TestNewStatusChangeView(t *testing.T) { 15 | screen := tcell.NewSimulationScreen("utf-8") 16 | _ = screen.Init() //nolint:errcheck 17 | defer screen.Fini() 18 | 19 | type args struct { 20 | issue *jira.Issue 21 | } 22 | tests := []struct { 23 | name string 24 | args args 25 | }{ 26 | {"should initialize & draw status change view", args{issue: &jira.Issue{}}}, 27 | } 28 | for _, tt := range tests { 29 | t.Run(tt.name, func(t *testing.T) { 30 | // given 31 | app.InitTestApp(screen) 32 | api := jira.NewJiraApiMock(func(w http.ResponseWriter, r *http.Request) { 33 | w.WriteHeader(200) 34 | _, _ = w.Write([]byte(`{ 35 | "transitions": [ 36 | { 37 | "id": "11", 38 | "name": "To Do" 39 | }, 40 | { 41 | "id": "21", 42 | "name": "In Progress" 43 | } 44 | ] 45 | }`)) //nolint:errcheck 46 | }) 47 | view := NewStatusChangeView(tt.args.issue, func() {}, api).(*statusChangeView) 48 | 49 | // when 50 | view.Init() 51 | for view.fuzzyFind == nil { 52 | <-time.After(10 * time.Millisecond) 53 | } 54 | query := "in progress" 55 | for _, key := range query { 56 | view.HandleKeyEvent(tcell.NewEventKey(-1, key, tcell.ModNone)) 57 | } 58 | i := 0 // keep app going for a while 59 | view.Resize(screen.Size()) 60 | for { 61 | view.Update() 62 | view.Draw(screen) 63 | i++ 64 | if i > 100000 { 65 | break 66 | } 67 | } 68 | 69 | // then 70 | var buffer bytes.Buffer 71 | contents, x, y := screen.GetContents() 72 | screen.Show() 73 | for i := 0; i < x*y; i++ { 74 | buffer.Write(contents[i].Bytes) 75 | } 76 | result := buffer.String() 77 | 78 | assert.Contains(t, result, "In Progress") 79 | assert.NotContains(t, result, "To Do") 80 | }) 81 | } 82 | } 83 | 84 | func Test_fjiraStatusChangeView_changeStatusForTicket(t *testing.T) { 85 | screen := tcell.NewSimulationScreen("utf-8") 86 | _ = screen.Init() //nolint:errcheck 87 | defer screen.Fini() 88 | 89 | type args struct { 90 | issue *jira.Issue 91 | status *jira.IssueTransition 92 | } 93 | 94 | tests := []struct { 95 | name string 96 | args args 97 | }{ 98 | {"should send change status request", args{issue: &jira.Issue{Key: "ABC", Id: "123"}, status: &jira.IssueTransition{Name: "In Progress", Id: "333"}}}, 99 | } 100 | for _, tt := range tests { 101 | t.Run(tt.name, func(t *testing.T) { 102 | // given 103 | app.InitTestApp(screen) 104 | changeStatusRequestSent := make(chan bool) 105 | api := jira.NewJiraApiMock(func(w http.ResponseWriter, r *http.Request) { 106 | w.WriteHeader(200) 107 | _, _ = w.Write([]byte(`{}`)) 108 | 109 | assert.Contains(t, r.RequestURI, tt.args.issue.Key) 110 | changeStatusRequestSent <- true 111 | }) 112 | view := NewStatusChangeView(tt.args.issue, func() {}, api).(*statusChangeView) 113 | 114 | // when 115 | go func() { 116 | view.changeStatusTo(tt.args.status) 117 | }() 118 | // wait for confirmation 119 | var confirmation *app.Confirmation 120 | for confirmation == nil { 121 | if c, ok := (app.GetApp().LastDrawable()).(*app.Confirmation); ok { 122 | confirmation = c 123 | } 124 | <-time.After(10 * time.Millisecond) 125 | } 126 | confirmation.HandleKeyEvent(tcell.NewEventKey(0, app.Yes, 0)) 127 | 128 | // then 129 | select { 130 | case <-changeStatusRequestSent: 131 | case <-time.After(3 * time.Second): 132 | t.Fail() 133 | } 134 | }) 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /internal/jira/jira_user_test.go: -------------------------------------------------------------------------------- 1 | package jira 2 | 3 | import ( 4 | "github.com/stretchr/testify/assert" 5 | "net/http" 6 | "reflect" 7 | "testing" 8 | ) 9 | 10 | func Test_httpJiraApi_FindUsers(t *testing.T) { 11 | type args struct { 12 | project string 13 | query string 14 | tokenType JiraTokenType 15 | } 16 | tests := []struct { 17 | name string 18 | args args 19 | want []User 20 | wantErr bool 21 | }{ 22 | {"should find users without error", 23 | args{project: "FJIR", tokenType: ApiToken}, 24 | []User{ 25 | {AccountId: "456", EmailAddress: "test@test.pl", DisplayName: "Mateusz Kulawik", Active: true, TimeZone: "Europe/Warsaw", Locale: "en_GB", AvatarUrls: nil}, 26 | {AccountId: "123", EmailAddress: "", DisplayName: "mateusz.test", Active: true, TimeZone: "Europe/Warsaw", Locale: "en_US", AvatarUrls: nil}, 27 | }, 28 | false, 29 | }, 30 | {"should find users with query without error", 31 | args{project: "FJIR", query: "test", tokenType: ApiToken}, 32 | []User{ 33 | {AccountId: "456", EmailAddress: "test@test.pl", DisplayName: "Mateusz Kulawik", Active: true, TimeZone: "Europe/Warsaw", Locale: "en_GB", AvatarUrls: nil}, 34 | {AccountId: "123", EmailAddress: "", DisplayName: "mateusz.test", Active: true, TimeZone: "Europe/Warsaw", Locale: "en_US", AvatarUrls: nil}, 35 | }, 36 | false, 37 | }, 38 | {"should find users with query without error", 39 | args{project: "FJIR", query: "test", tokenType: PersonalToken}, 40 | []User{ 41 | {AccountId: "456", EmailAddress: "test@test.pl", DisplayName: "Mateusz Kulawik", Active: true, TimeZone: "Europe/Warsaw", Locale: "en_GB", AvatarUrls: nil}, 42 | {AccountId: "123", EmailAddress: "", DisplayName: "mateusz.test", Active: true, TimeZone: "Europe/Warsaw", Locale: "en_US", AvatarUrls: nil}, 43 | }, 44 | false, 45 | }, 46 | } 47 | for _, tt := range tests { 48 | t.Run(tt.name, func(t *testing.T) { 49 | api := NewJiraApiMockWithTokenType(func(w http.ResponseWriter, r *http.Request) { 50 | w.WriteHeader(200) 51 | body := ` 52 | [ 53 | { 54 | "accountId": "456", 55 | "accountType": "atlassian", 56 | "emailAddress": "test@test.pl", 57 | "displayName": "Mateusz Kulawik", 58 | "active": true, 59 | "timeZone": "Europe/Warsaw", 60 | "locale": "en_GB" 61 | }, 62 | { 63 | "accountId": "123", 64 | "accountType": "atlassian", 65 | "emailAddress": "", 66 | "displayName": "mateusz.test", 67 | "active": true, 68 | "timeZone": "Europe/Warsaw", 69 | "locale": "en_US" 70 | } 71 | ] 72 | ` 73 | w.Write([]byte(body)) //nolint:errcheck 74 | }, tt.args.tokenType) 75 | var got []User 76 | var err error 77 | if tt.args.query == "" { 78 | got, err = api.FindUsers(tt.args.project) 79 | } else { 80 | got, err = api.FindUsersWithQuery(tt.args.project, tt.args.query) 81 | } 82 | if (err != nil) != tt.wantErr { 83 | t.Errorf("FindUsers() error = %v, wantErr %v", err, tt.wantErr) 84 | return 85 | } 86 | if !reflect.DeepEqual(got, tt.want) { 87 | t.Errorf("FindUsers() got = %v, want %v", got, tt.want) 88 | } 89 | }) 90 | } 91 | } 92 | 93 | func Test_httpJiraApi_FindUsers_returnError(t *testing.T) { 94 | type args struct { 95 | project string 96 | query string 97 | } 98 | tests := []struct { 99 | name string 100 | args args 101 | wantErr bool 102 | }{ 103 | {"should return error when search failed", 104 | args{project: "FJIR"}, 105 | true, 106 | }, 107 | } 108 | for _, tt := range tests { 109 | t.Run(tt.name, func(t *testing.T) { 110 | // given 111 | api := NewJiraApiMock(func(w http.ResponseWriter, r *http.Request) { 112 | w.WriteHeader(500) 113 | }) 114 | 115 | // when 116 | _, err := api.FindUsersWithQuery(tt.args.project, tt.args.query) 117 | 118 | // then 119 | assert.Error(t, err) 120 | }) 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /internal/app/action_bar.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "fmt" 5 | "github.com/gdamore/tcell/v2" 6 | "unicode/utf8" 7 | ) 8 | 9 | type ActionBarAction int 10 | 11 | type ActionBar struct { 12 | Action chan ActionBarAction 13 | Y int 14 | X int 15 | screenX, screenY int 16 | items []ActionBarItem 17 | vAlign int 18 | hAlign int 19 | style tcell.Style 20 | } 21 | 22 | const ( 23 | Bottom = -1 24 | Top = 1 25 | Left = 1 26 | Right = -1 27 | ActionBarMaxItems = 10 28 | ActionBarItemPadding = 1 29 | MessageLabelNone = "-" 30 | ) 31 | 32 | func NewActionBar(vAlign int, hAlign int) *ActionBar { 33 | return &ActionBar{ 34 | Action: make(chan ActionBarAction), 35 | Y: 0, 36 | screenX: 0, 37 | screenY: 0, 38 | vAlign: vAlign, 39 | hAlign: hAlign, 40 | items: make([]ActionBarItem, 0, ActionBarMaxItems), 41 | style: DefaultStyle(), 42 | } 43 | } 44 | 45 | func (b *ActionBar) AddTextItem(id string, text string) { 46 | item := NewActionBarItem(len(b.items)+1, text, 0, 0) 47 | b.AddItem(item) 48 | } 49 | 50 | func (b *ActionBar) AddItem(item *ActionBarItem) { 51 | item.text = fmt.Sprintf("%s%s", item.Text1, item.Text2) 52 | item.x = b.getNextItemX(len(b.items) - 1) 53 | item.y = b.Y 54 | b.items = append(b.items, *item) 55 | b.Resize(b.screenX, b.screenY) 56 | } 57 | 58 | func (b *ActionBar) AddItemWithStyles(text1 string, text2 string, text1Style tcell.Style, text2Style tcell.Style) { 59 | item := &ActionBarItem{ 60 | Id: len(b.items) + 1, 61 | Text1: text1, 62 | Text2: text2, 63 | Text1Style: text1Style, 64 | Text2Style: text2Style, 65 | } 66 | b.AddItem(item) 67 | b.Resize(b.screenX, b.screenY) 68 | } 69 | 70 | func (b *ActionBar) GetItem(index int) *ActionBarItem { 71 | if index > len(b.items)-1 { 72 | return nil 73 | } 74 | return &b.items[index] 75 | } 76 | 77 | func (b *ActionBar) RemoveItem(id int) { 78 | for i, item := range b.items { 79 | if item.Id == id { 80 | b.RemoveItemAtIndex(i) 81 | return 82 | } 83 | } 84 | } 85 | 86 | func (b *ActionBar) RemoveItemAtIndex(index int) { 87 | if index >= 0 { 88 | b.items = append(b.items[:index], b.items[index+1:]...) 89 | b.Resize(b.screenX, b.screenY) 90 | } 91 | } 92 | 93 | func (b *ActionBar) TrimItemsTo(index int) { 94 | if index > 0 { 95 | b.items = b.items[:index] 96 | b.Resize(b.screenX, b.screenY) 97 | } 98 | } 99 | 100 | func (b *ActionBar) Clear() { 101 | b.items = nil 102 | b.Resize(b.screenX, b.screenY) 103 | } 104 | 105 | func (b *ActionBar) Draw(screen tcell.Screen) { 106 | for _, item := range b.items { 107 | DrawText(screen, item.x, item.y, item.Text1Style, item.Text1) 108 | DrawText(screen, item.x+len(item.Text1), item.y, item.Text2Style, item.Text2) 109 | DrawText(screen, item.x+len(item.Text1)+len(item.Text2), item.y, b.style, " ") 110 | } 111 | } 112 | 113 | func (b *ActionBar) Update() { 114 | // do nothing 115 | } 116 | 117 | func (b *ActionBar) HandleKeyEvent(ev *tcell.EventKey) { 118 | for _, item := range b.items { 119 | if item.TriggerRune > 0 && (item.TriggerRune == ev.Rune() || item.TriggerRune-32 == ev.Rune()) { 120 | b.Action <- ActionBarAction(item.Id) 121 | return 122 | } else if item.TriggerKey > 0 && item.TriggerKey == ev.Key() { 123 | b.Action <- ActionBarAction(item.Id) 124 | return 125 | } 126 | } 127 | } 128 | 129 | func (b *ActionBar) Resize(screenX, screenY int) { 130 | b.screenX = screenX 131 | b.screenY = screenY 132 | switch b.vAlign { 133 | case Bottom: 134 | b.Y = screenY - 1 135 | case Top: 136 | b.Y = 0 137 | } 138 | for i := range b.items { 139 | b.items[i].x = b.getNextItemX(i) 140 | b.items[i].y = b.Y 141 | } 142 | } 143 | 144 | func (b *ActionBar) Destroy() { 145 | close(b.Action) 146 | } 147 | 148 | func (b *ActionBar) getNextItemX(startIndex int) int { 149 | switch b.hAlign { 150 | case Right: 151 | if startIndex-1 < 0 { 152 | return b.screenX 153 | } 154 | item := b.items[startIndex-1] 155 | return item.x - utf8.RuneCountInString(item.text) - ActionBarItemPadding 156 | default: 157 | if startIndex-1 < 0 { 158 | return 0 159 | } 160 | item := b.items[startIndex-1] 161 | return item.x + utf8.RuneCountInString(item.text) + ActionBarItemPadding 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /internal/workspaces/settings.go: -------------------------------------------------------------------------------- 1 | package workspaces 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "github.com/mk-5/fjira/internal/jira" 7 | os2 "github.com/mk-5/fjira/internal/os" 8 | "gopkg.in/yaml.v3" 9 | "os" 10 | ) 11 | 12 | type Settings struct { 13 | Current string `json:"current" yaml:"current"` 14 | Workspaces map[string]WorkspaceSettings 15 | } 16 | 17 | type WorkspaceSettings struct { 18 | JiraRestUrl string `json:"jiraRestUrl" yaml:"jiraRestUrl"` 19 | JiraToken string `json:"jiraToken" yaml:"jiraToken"` 20 | JiraUsername string `json:"jiraUsername" yaml:"jiraUsername"` 21 | JiraTokenType jira.JiraTokenType `json:"jiraTokenType" yaml:"jiraTokenType"` 22 | Workspace string `json:"-" yaml:"-"` 23 | } 24 | 25 | type SettingsStorage interface { //nolint 26 | Write(workspace string, settings *WorkspaceSettings) error 27 | Read(workspace string) (*WorkspaceSettings, error) 28 | ReadAllWorkspaces() ([]string, error) 29 | ReadCurrentWorkspace() (string, error) 30 | SetCurrentWorkspace(workspace string) error 31 | ConfigDir() (string, error) 32 | } 33 | 34 | var ( 35 | WorkspaceNotFoundErr = errors.New("workspace doesn't exist") 36 | ) 37 | 38 | const ( 39 | EmptyWorkspace = "" 40 | DefaultWorkspaceName = "default" 41 | SettingsFilename = "fjira.yaml" 42 | ) 43 | 44 | type userHomeSettingsStorage struct{} 45 | 46 | func NewUserHomeSettingsStorage() SettingsStorage { 47 | return &userHomeSettingsStorage{} 48 | } 49 | 50 | func (s *userHomeSettingsStorage) Read(workspace string) (*WorkspaceSettings, error) { 51 | settings, err := s.createOrGetSettings() 52 | if err != nil { 53 | return nil, err 54 | } 55 | if w, ok := settings.Workspaces[workspace]; ok { 56 | w.Workspace = workspace 57 | return &w, nil 58 | } 59 | return nil, WorkspaceNotFoundErr 60 | } 61 | 62 | func (s *userHomeSettingsStorage) Write(workspace string, workspaceSettings *WorkspaceSettings) error { 63 | settings, err := s.createOrGetSettings() 64 | if err != nil { 65 | return err 66 | } 67 | settings.Workspaces[workspace] = *workspaceSettings 68 | err = s.writeSettings(settings) 69 | return err 70 | } 71 | 72 | func (s *userHomeSettingsStorage) writeSettings(settings *Settings) error { 73 | settingsFilePath, err := s.settingsFilePath() 74 | if err != nil { 75 | return err 76 | } 77 | settingsYml, err := yaml.Marshal(settings) 78 | if err != nil { 79 | return err 80 | } 81 | err = os.WriteFile(settingsFilePath, settingsYml, 0644) 82 | return err 83 | } 84 | 85 | func (s *userHomeSettingsStorage) createOrGetSettings() (*Settings, error) { 86 | settingsFilePath, err := s.settingsFilePath() 87 | if err != nil { 88 | return nil, err 89 | } 90 | var settings Settings 91 | settingsBytes, err := os.ReadFile(settingsFilePath) 92 | if errors.Is(err, os.ErrNotExist) { 93 | settings = Settings{ 94 | Current: DefaultWorkspaceName, 95 | Workspaces: map[string]WorkspaceSettings{}, 96 | } 97 | } else if err != nil { 98 | return nil, err 99 | } 100 | err = yaml.Unmarshal(settingsBytes, &settings) 101 | if err != nil { 102 | return nil, err 103 | } 104 | // temporary for migration, "" was a default workspace before. Should be removed after some time 105 | for k := range settings.Workspaces { 106 | if k == "" { 107 | if settings.Current == "" { 108 | settings.Current = DefaultWorkspaceName 109 | } 110 | settings.Workspaces[DefaultWorkspaceName] = settings.Workspaces[k] 111 | delete(settings.Workspaces, k) 112 | _ = s.writeSettings(&settings) 113 | break 114 | } 115 | } 116 | return &settings, nil 117 | } 118 | 119 | func (s *userHomeSettingsStorage) ReadCurrentWorkspace() (string, error) { 120 | settings, err := s.createOrGetSettings() 121 | if err != nil { 122 | return "", err 123 | } 124 | return settings.Current, nil 125 | } 126 | 127 | func (s *userHomeSettingsStorage) SetCurrentWorkspace(workspace string) error { 128 | settings, err := s.createOrGetSettings() 129 | if err != nil { 130 | return err 131 | } 132 | if _, ok := settings.Workspaces[workspace]; ok { 133 | settings.Current = workspace 134 | return s.writeSettings(settings) 135 | } 136 | return WorkspaceNotFoundErr 137 | } 138 | 139 | func (s *userHomeSettingsStorage) ReadAllWorkspaces() ([]string, error) { 140 | settings, err := s.createOrGetSettings() 141 | if err != nil { 142 | return nil, err 143 | } 144 | w := make([]string, 0, len(settings.Workspaces)) 145 | for k := range settings.Workspaces { 146 | w = append(w, k) 147 | } 148 | return w, nil 149 | } 150 | 151 | func (s *userHomeSettingsStorage) ConfigDir() (string, error) { 152 | configDir := os2.MustGetFjiraHomeDir() 153 | if _, err := os.Stat(configDir); errors.Is(err, os.ErrNotExist) { 154 | err := os.Mkdir(configDir, os.ModePerm) 155 | if err != nil { 156 | return "", err 157 | } 158 | } 159 | return configDir, nil 160 | } 161 | 162 | func (s *userHomeSettingsStorage) settingsFilePath() (string, error) { 163 | configDir, err := s.ConfigDir() 164 | if err != nil { 165 | return "", err 166 | } 167 | return fmt.Sprintf("%s/%s", configDir, SettingsFilename), err 168 | } 169 | -------------------------------------------------------------------------------- /internal/ui/messages.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | const ( 4 | MessageCreateNewWorkspace = "Create new workspace " 5 | MessageEditWorkspace = "Edit workspace " 6 | MessageEnterUsername = "Jira username/email: " 7 | MessageEnterJiraUrl = "Jira URL: " 8 | MessageEnterJiraUrlExample = "[ex. https://my-jira.atlassian.net]: " 9 | MessageSelectWorkspace = "Select workspace or ESC to cancel" 10 | MessageSelectWorkspaceSuccess = "Workspace has been successfully switched to %s" 11 | MessageQuestionMark = "? " 12 | MessageEnterJiraApiToken = "Jira Api Token: " 13 | MessageEnterJiraTokenType = "Jira Token Type: " 14 | MessageEnterJiraTokenNumber = "Enter a number (Default is 1): " 15 | MessageProjectLabel = "Project: " 16 | MessageIssueLabel = "Issue: " 17 | MessageLabelStatus = "Status: " 18 | MessageLabelSprint = "Sprint: " 19 | MessageLabelSprintType = "Type: " 20 | MessageLabelSprintStartDate = "Start: " 21 | MessageLabelSprintEndDate = "End: " 22 | MessageTypeStatus = "Type: " 23 | MessageLabelAssignee = "Assignee: " 24 | MessageLabelLabel = "Label: " 25 | MessageLabelReporter = "Reporter: " 26 | MessageJqlLabel = "JQL: " 27 | MessageSearchIssuesLoading = "Fetching" 28 | MessageSearchLabelsLoading = "Fetching" 29 | MessageSearchBoardsLoading = "Fetching" 30 | MessageSelectIssue = "Select issue or ESC to cancel" 31 | MessageSelectUser = "Select user or ESC to cancel" 32 | MessageSelectLabel = "Select label or ESC to cancel" 33 | MessageSelectBoard = "Select board or ESC to cancel" 34 | MessageSelectFilter = "Select filter or ESC to cancel" 35 | MessageSearchProjectsLoading = "Fetching projects" 36 | MessageSelectProject = "Select project or ESC to exit" 37 | MessageChangingAssigneeTo = "Are you sure about changing %s assignee to [%s]?" 38 | MessageCannotAssignUser = "Cannot assign user %s to ticket %s. Reason: %s AccountId: %s" 39 | MessageCannotAddLabel = "Cannot add label %s to ticket %s. Reason: %s" 40 | MessageCannotAddComment = "Cannot add comment to ticket %s. Reason: %s" 41 | MessageAssignSuccess = "User %s has been successfully assigned to issue %s." 42 | MessageAddLabelSuccess = "Label %s has been successfully added to issue %s." 43 | MessageCommentSuccess = "Comment has been successfully added to issue %s." 44 | MessageUsersFuzzyFind = "Select new assignee or ESC to cancel" 45 | MessageLabelFuzzyFind = "Select existing label or type new one or ESC to cancel" 46 | MessageCannotFindStatusForColumn = "Cannot find valid transition status." 47 | MessageAssigningUser = "Assigning user" 48 | MessageAddingLabel = "Adding label" 49 | MessageAddingComment = "Adding comment" 50 | MessageUnassigned = "Unassigned" 51 | MessageChangingStatusTo = "Are you sure about changing %s status to [%s]?" 52 | MessageStatusFuzzyFind = "Select status or ESC to cancel" 53 | MessageChangingStatus = "Changing status" 54 | MessageChangeStatusSuccess = "Status for issue %s has been successfully changed to %s." 55 | MessageAll = "All" 56 | MessageTypeCommentAndSave = "Type new comment, and press F1 to save:" 57 | MessageTypeJqlAndSave = "Type new JQL, and press F1 to save:" 58 | MessageSummary = "Summary" 59 | MessageDescription = "Description" 60 | MessageLabels = "Labels" 61 | MessageChangeStatus = "Change status " 62 | MessageByStatus = "by status " 63 | MessageByAssignee = "by assignee " 64 | MessageByLabel = "by label " 65 | MessageBoards = "boards" 66 | MessageAssignUser = "Assign user " 67 | MessageComment = "Comment " 68 | MessageLabel = "Label " 69 | MessageYes = "Yes " 70 | MessageOpen = "Open " 71 | MessageSave = "Save " 72 | MessageScroll = "Scroll " 73 | MessageNavigate = "Navigate " 74 | MessageMoveIssue = "Move issue " 75 | MessageSelect = "Select " 76 | MessageUnselect = "Unselect " 77 | MessageNew = "New " 78 | MessageDelete = "Delete " 79 | MessageJqlFuzzyFind = "Select or type new jql or ESC to cancel" 80 | MessageJqlAddNew = "Are you sure about adding a new JQL [%s] ?" 81 | MessageJqlRemoveConfirm = "Do you really want to remove JQL [%s] ?" 82 | MessageCannotAddNewJql = "Cannot add new jql. Reason: %s" 83 | MessageJqlAddSuccess = "New JQL has been successfully added to your workspace." 84 | MessageJqlRemoveSuccess = "JQL has been successfully removed from your workspace." 85 | MessageCustomJql = "Custom JQL" 86 | ) 87 | -------------------------------------------------------------------------------- /internal/jira/jira_search_test.go: -------------------------------------------------------------------------------- 1 | package jira 2 | 3 | import ( 4 | "net/http" 5 | "testing" 6 | ) 7 | 8 | func Test_httpJiraApi_Search(t *testing.T) { 9 | type args struct { 10 | query string 11 | } 12 | tests := []struct { 13 | name string 14 | args args 15 | want []Issue 16 | want1 int32 17 | wantErr bool 18 | }{ 19 | {"should do search without error", 20 | args{query: "test"}, 21 | []Issue{ 22 | {"ISSUE-1", IssueFields{Description: "Desc1", Status: Status{Name: "Status1"}}, ""}, 23 | {"ISSUE-2", IssueFields{Description: "Desc2", Status: Status{Name: "Status2"}}, ""}, 24 | {"ISSUE-3", IssueFields{Description: "Desc3", Status: Status{Name: "Status3"}}, ""}, 25 | }, 26 | 3, 27 | false, 28 | }, 29 | {"should do search without error using issue key", 30 | args{query: "ISSUE-1"}, 31 | []Issue{ 32 | {"ISSUE-1", IssueFields{Description: "Desc1", Status: Status{Name: "Status1"}}, ""}, 33 | {"ISSUE-2", IssueFields{Description: "Desc2", Status: Status{Name: "Status2"}}, ""}, 34 | {"ISSUE-3", IssueFields{Description: "Desc3", Status: Status{Name: "Status3"}}, ""}, 35 | }, 36 | 3, 37 | false, 38 | }, 39 | } 40 | for _, tt := range tests { 41 | t.Run(tt.name, func(t *testing.T) { 42 | api := NewJiraApiMock(func(w http.ResponseWriter, r *http.Request) { 43 | w.WriteHeader(200) 44 | body := ` 45 | { 46 | "expand": "schema,names", 47 | "startAt": 0, 48 | "maxResults": 100, 49 | "total": 3, 50 | "issues": [ 51 | { 52 | "key": "ISSUE-1", 53 | "fields": { 54 | "summary": "Issue summary 1", 55 | "description": "Desc1", 56 | "status": { 57 | "name": "Status1" 58 | } 59 | } 60 | }, 61 | { 62 | "key": "ISSUE-2", 63 | "fields": { 64 | "summary": "Issue summary 2", 65 | "description": "Desc2", 66 | "status": { 67 | "name": "Status2" 68 | } 69 | } 70 | }, 71 | { 72 | "key": "ISSUE-3", 73 | "fields": { 74 | "summary": "Issue summary 3", 75 | "description": "Desc3", 76 | "status": { 77 | "name": "Status3" 78 | } 79 | } 80 | } 81 | ] 82 | } 83 | ` 84 | w.Write([]byte(body)) //nolint:errcheck 85 | }) 86 | got, got1, err := api.Search(tt.args.query) 87 | if (err != nil) != tt.wantErr { 88 | t.Errorf("Search() error = %v, wantErr %v", err, tt.wantErr) 89 | return 90 | } 91 | for i := range got { 92 | if got[i].Key != tt.want[i].Key { 93 | t.Errorf("Search() got = %v, want %v", got[i].Key, tt.want[i].Key) 94 | } 95 | } 96 | if got1 != tt.want1 { 97 | t.Errorf("Search() got1 = %v, want %v", got1, tt.want1) 98 | } 99 | }) 100 | } 101 | } 102 | 103 | func Test_httpJiraApi_SearchJql(t *testing.T) { 104 | type args struct { 105 | query string 106 | } 107 | tests := []struct { 108 | name string 109 | args args 110 | want []Issue 111 | wantErr bool 112 | }{ 113 | {"should do search-jql without error", 114 | args{query: "test"}, 115 | []Issue{ 116 | {"ISSUE-1", IssueFields{Description: "Desc1", Status: Status{Name: "Status1"}}, ""}, 117 | {"ISSUE-2", IssueFields{Description: "Desc2", Status: Status{Name: "Status2"}}, ""}, 118 | {"ISSUE-3", IssueFields{Description: "Desc3", Status: Status{Name: "Status3"}}, ""}, 119 | }, 120 | false, 121 | }, 122 | } 123 | for _, tt := range tests { 124 | t.Run(tt.name, func(t *testing.T) { 125 | api := NewJiraApiMock(func(w http.ResponseWriter, r *http.Request) { 126 | w.WriteHeader(200) 127 | body := ` 128 | { 129 | "expand": "schema,names", 130 | "startAt": 0, 131 | "maxResults": 100, 132 | "total": 3, 133 | "issues": [ 134 | { 135 | "key": "ISSUE-1", 136 | "fields": { 137 | "summary": "Issue summary 1", 138 | "description": "Desc1", 139 | "status": { 140 | "name": "Status1" 141 | } 142 | } 143 | }, 144 | { 145 | "key": "ISSUE-2", 146 | "fields": { 147 | "summary": "Issue summary 2", 148 | "description": "Desc2", 149 | "status": { 150 | "name": "Status2" 151 | } 152 | } 153 | }, 154 | { 155 | "key": "ISSUE-3", 156 | "fields": { 157 | "summary": "Issue summary 3", 158 | "description": "Desc3", 159 | "status": { 160 | "name": "Status3" 161 | } 162 | } 163 | } 164 | ] 165 | } 166 | ` 167 | w.Write([]byte(body)) //nolint:errcheck 168 | }) 169 | got, err := api.SearchJql(tt.args.query) 170 | if (err != nil) != tt.wantErr { 171 | t.Errorf("Search() error = %v, wantErr %v", err, tt.wantErr) 172 | return 173 | } 174 | for i := range got { 175 | if got[i].Key != tt.want[i].Key { 176 | t.Errorf("Search() got = %v, want %v", got[i].Key, tt.want[i].Key) 177 | } 178 | } 179 | }) 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /internal/jira/jira_issue_test.go: -------------------------------------------------------------------------------- 1 | package jira 2 | 3 | import ( 4 | "net/http" 5 | "reflect" 6 | "testing" 7 | ) 8 | 9 | func Test_httpJiraApi_GetIssueDetailed(t *testing.T) { 10 | type args struct { 11 | id string 12 | } 13 | tests := []struct { 14 | name string 15 | args args 16 | want *Issue 17 | wantErr bool 18 | }{ 19 | {"should get detailed jira issue", 20 | args{id: "10011"}, 21 | &Issue{ 22 | Key: "JWC-3", Id: "10011", 23 | Fields: IssueFields{ 24 | Summary: "Tutorial - create tutorial", 25 | Description: "Lorem ipsum", 26 | Project: Project{Id: "10003", Name: "JIRA WORK CHART", Key: "JWC"}, 27 | Reporter: struct { 28 | AccountId string `json:"accountId"` 29 | DisplayName string `json:"displayName"` 30 | }(struct { 31 | AccountId string 32 | DisplayName string 33 | }{"607f55ba074a0b006a6cb482", "Mateusz Kulawik"}), 34 | Assignee: struct { 35 | AccountId string `json:"accountId"` 36 | DisplayName string `json:"displayName"` 37 | }(struct { 38 | AccountId string 39 | DisplayName string 40 | }{"", ""}), 41 | Type: IssueType{Name: "Task"}, 42 | Labels: []string{"TestLabel"}, 43 | Status: Status{Id: "10013", Name: "Done"}, 44 | Comment: struct { 45 | Comments []Comment `json:"comments"` 46 | MaxResults int32 `json:"maxResults"` 47 | Total int32 `json:"total"` 48 | StartAt int32 `json:"startAt"` 49 | }(struct { 50 | Comments []Comment 51 | MaxResults int32 52 | Total int32 53 | StartAt int32 54 | }{ 55 | Comments: []Comment{ 56 | {Body: "Comment 123-ABC", Created: "2022-06-09T22:53:42.057+0200", Author: User{DisplayName: "Mateusz Kulawik"}}, 57 | }, 58 | MaxResults: 1, Total: 1, StartAt: 0}, 59 | ), 60 | }, 61 | }, 62 | false, 63 | }, 64 | } 65 | for _, tt := range tests { 66 | t.Run(tt.name, func(t *testing.T) { 67 | api := NewJiraApiMock(func(w http.ResponseWriter, r *http.Request) { 68 | w.WriteHeader(200) 69 | body := ` 70 | { 71 | "expand": "renderedFields,names,schema,operations,editmeta,changelog,versionedRepresentations,customfield_10010.requestTypePractice", 72 | "id": "10011", 73 | "self": "https://test/rest/api/2/issue/10011", 74 | "key": "JWC-3", 75 | "fields": { 76 | "issuetype": { 77 | "id": "10013", 78 | "description": "A small, distinct piece of work.", 79 | "name": "Task", 80 | "subtask": false 81 | }, 82 | "timespent": 14400, 83 | "project": { 84 | "id": "10003", 85 | "key": "JWC", 86 | "name": "JIRA WORK CHART", 87 | "projectTypeKey": "software" 88 | }, 89 | "fixVersions": [], 90 | "aggregatetimespent": 14400, 91 | "resolutiondate": "2022-02-22T00:27:11.861+0100", 92 | "workratio": -1, 93 | "issuerestriction": { 94 | "issuerestrictions": {}, 95 | "shouldDisplay": true 96 | }, 97 | "lastViewed": "2022-02-22T00:27:17.356+0100", 98 | "created": "2021-10-02T22:34:22.521+0200", 99 | "aggregatetimeoriginalestimate": null, 100 | "timeestimate": 0, 101 | "versions": [], 102 | "issuelinks": [], 103 | "assignee": null, 104 | "updated": "2022-02-22T00:27:19.792+0100", 105 | "status": { 106 | "description": "", 107 | "name": "Done", 108 | "id": "10013" 109 | }, 110 | "labels": ["TestLabel"], 111 | "description": "Lorem ipsum", 112 | "summary": "Tutorial - create tutorial", 113 | "creator": { 114 | "accountId": "607f55ba074a0b006a6cb482", 115 | "emailAddress": "test@test.dev", 116 | "displayName": "Mateusz Kulawik", 117 | "active": true, 118 | "timeZone": "Europe/Warsaw", 119 | "accountType": "atlassian" 120 | }, 121 | "subtasks": [], 122 | "reporter": { 123 | "accountId": "607f55ba074a0b006a6cb482", 124 | "emailAddress": "test@test.dev", 125 | "displayName": "Mateusz Kulawik", 126 | "active": true, 127 | "timeZone": "Europe/Warsaw", 128 | "accountType": "atlassian" 129 | }, 130 | "comment": { 131 | "comments": [ 132 | { 133 | "author": { 134 | "displayName": "Mateusz Kulawik" 135 | }, 136 | "body": "Comment 123-ABC", 137 | "created": "2022-06-09T22:53:42.057+0200", 138 | "updated": "2022-06-09T22:53:42.057+0200" 139 | } 140 | ], 141 | "maxResults": 1, 142 | "total": 1, 143 | "startAt": 0 144 | } 145 | } 146 | } 147 | ` 148 | w.Write([]byte(body)) //nolint:errcheck 149 | }) 150 | got, err := api.GetIssueDetailed(tt.args.id) 151 | if (err != nil) != tt.wantErr { 152 | t.Errorf("GetIssueDetailed() error = %v, wantErr %v", err, tt.wantErr) 153 | return 154 | } 155 | if !reflect.DeepEqual(got, tt.want) { 156 | t.Errorf("GetIssueDetailed() got = %v, want %v", got, tt.want) 157 | } 158 | }) 159 | } 160 | } 161 | --------------------------------------------------------------------------------