├── .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 |
--------------------------------------------------------------------------------