diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml new file mode 100644 index 0000000..0b4b59a --- /dev/null +++ b/.github/workflows/e2e-tests.yml @@ -0,0 +1,32 @@ +name: e2e-tests + +on: + push: + branches: ["main"] + pull_request: + paths: + - "go.*" + - "**/*.go" + - ".github/workflows/*.yml" + +env: + GO_VERSION: '1.23.3' + +jobs: + test: + strategy: + matrix: + os: [ubuntu-latest, macos-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: ${{ env.GO_VERSION }} + - name: Install + run: go install . + - name: Run tests + run: | + cd tests + ./test.sh diff --git a/README.md b/README.md index 2d8e96a..2606f36 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Usage

-💾 Install +💾 Installation --- **homebrew**: @@ -33,34 +33,45 @@ file. ### Using a config file Create a toml file that looks like the following. The default location for this -file is `~/.config/punchout/punchout.toml`. +file is `~/.config/punchout/punchout.toml`. The configuration needed for +authenticating against your JIRA installation (on-premise or cloud) will depend +on the kind of the installation. ```toml [jira] jira_url = "https://jira.company.com" -jira_token = "XXX" + +# for on-premise installations +installation_type = "onpremise" +jira_token = "your personal access token" + +# for cloud installations +installation_type = "cloud" +jira_token = "your API token" +jira_username = "example@example.com" + # put whatever JQL you want to query for jql = "assignee = currentUser() AND updatedDate >= -14d ORDER BY updatedDate DESC" + # I don't know how many people will find use for this. -# I need this, since the JIRA server I use runs 5 hours behind +# I need this, since the JIRA on-premise server I use runs 5 hours behind # the actual time, for whatever reason 🤷 jira_time_delta_mins = 300 ``` -*Note: `punchout` only supports [on-premise] installations of JIRA for now. I -might add support for cloud installations in the future.* - ### Using command line flags Use `punchout -h` for help. ```bash punchout \ - db-path='/path/to/punchout/db/file.db' \ - jira-url='https://jira.company.com' \ - jira-token='XXX' \ - jql='assignee = currentUser() AND updatedDate >= -14d ORDER BY updatedDate DESC' \ - jira-time-delta-mins='300' \ + -db-path='/path/to/punchout/db/file.db' \ + -jira-url='https://jira.company.com' \ + -jira-installation-type 'cloud' \ + -jira-token='XXX' \ + -jira-username='example@example.com' \ + -jql='assignee = currentUser() AND updatedDate >= -14d ORDER BY updatedDate DESC' \ + -jira-time-delta-mins='300' ``` Both the config file and the command line flags can be used in conjunction, but @@ -68,9 +79,9 @@ the latter will take precedence over the former. ```bash punchout \ - config-file-path='/path/to/punchout/config/file.toml' \ - jira-token='XXX' \ - jql='assignee = currentUser() AND updatedDate >= -14d ORDER BY updatedDate DESC' + -config-file-path='/path/to/punchout/config/file.toml' \ + -jira-token='XXX' \ + -jql='assignee = currentUser() AND updatedDate >= -14d ORDER BY updatedDate DESC' ``` 🖥️ Screenshots @@ -114,8 +125,8 @@ General General List Controls - h/ Move cursor up - k/ Move cursor down + k/ Move cursor up + j/ Move cursor down h Go to previous page l Go to next page / Start filtering diff --git a/cmd/config.go b/cmd/config.go index b84c233..6046753 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -4,11 +4,18 @@ import ( "github.com/BurntSushi/toml" ) +const ( + jiraInstallationTypeOnPremise = "onpremise" + jiraInstallationTypeCloud = "cloud" +) + type JiraConfig struct { + InstallationType string `toml:"installation_type"` JiraURL *string `toml:"jira_url"` - JiraToken *string `toml:"jira_token"` Jql *string - JiraTimeDeltaMins *int `toml:"jira_time_delta_mins"` + JiraTimeDeltaMins int `toml:"jira_time_delta_mins"` + JiraToken *string `toml:"jira_token"` + JiraUsername *string `toml:"jira_username"` } type POConfig struct { @@ -16,7 +23,6 @@ type POConfig struct { } func readConfig(filePath string) (POConfig, error) { - var config POConfig _, err := toml.DecodeFile(expandTilde(filePath), &config) if err != nil { @@ -24,5 +30,4 @@ func readConfig(filePath string) (POConfig, error) { } return config, nil - } diff --git a/cmd/root.go b/cmd/root.go index 3588a39..20102e9 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -3,11 +3,13 @@ package cmd import ( "flag" "fmt" + "net/http" "os" "os/user" "strconv" - jira "github.com/andygrunwald/go-jira/v2/onpremise" + jiraCloud "github.com/andygrunwald/go-jira/v2/cloud" + jiraOnPremise "github.com/andygrunwald/go-jira/v2/onpremise" "github.com/dhth/punchout/internal/ui" ) @@ -17,16 +19,17 @@ func die(msg string, args ...any) { } var ( + jiraInstallationType = flag.String("jira-installation-type", "", "JIRA installation type; allowed values: [cloud, onpremise]") jiraURL = flag.String("jira-url", "", "URL of the JIRA server") - jiraToken = flag.String("jira-token", "", "personal access token for the JIRA server") - jql = flag.String("jql", "", "JQL to use to query issues at startup") + jiraToken = flag.String("jira-token", "", "jira token (PAT for on-premise installation, API token for cloud installation)") + jiraUsername = flag.String("jira-username", "", "username for authentication") + jql = flag.String("jql", "", "JQL to use to query issues") jiraTimeDeltaMinsStr = flag.String("jira-time-delta-mins", "", "Time delta (in minutes) between your timezone and the timezone of the server; can be +/-") - listConfig = flag.Bool("list-config", false, "Whether to only print out the config that punchout will use or not") + listConfig = flag.Bool("list-config", false, "print the config that punchout will use") ) func Execute() { currentUser, err := user.Current() - if err != nil { die("Error getting your home directory, explicitly specify the path for the config file using -config-file-path") } @@ -58,70 +61,112 @@ func Execute() { if *jiraTimeDeltaMinsStr != "" { jiraTimeDeltaMins, err = strconv.Atoi(*jiraTimeDeltaMinsStr) if err != nil { - die("could't convert jira-time-delta-mins to a number") + die("couldn't convert jira-time-delta-mins to a number") } } - poCfg, err := readConfig(*configFilePath) + cfg, err := readConfig(*configFilePath) if err != nil { - die("error reading config at %s: %s", *configFilePath, err.Error()) + die("error reading config: %s.\n", err.Error()) + } + + if *jiraInstallationType != "" { + cfg.Jira.InstallationType = *jiraInstallationType } if *jiraURL != "" { - poCfg.Jira.JiraURL = jiraURL + cfg.Jira.JiraURL = jiraURL } if *jiraToken != "" { - poCfg.Jira.JiraToken = jiraToken + cfg.Jira.JiraToken = jiraToken } - if *jql != "" { - poCfg.Jira.Jql = jql + if *jiraUsername != "" { + cfg.Jira.JiraUsername = jiraUsername } - if *jiraTimeDeltaMinsStr != "" { - poCfg.Jira.JiraTimeDeltaMins = &jiraTimeDeltaMins + + if *jql != "" { + cfg.Jira.Jql = jql } - configKeyMaxLen := 40 - if *listConfig { - fmt.Fprint(os.Stdout, "Config:\n\n") - fmt.Fprintf(os.Stdout, "%s%s\n", ui.RightPadTrim("Config File Path", configKeyMaxLen), *configFilePath) - fmt.Fprintf(os.Stdout, "%s%s\n", ui.RightPadTrim("DB File Path", configKeyMaxLen), dbPathFull) - fmt.Fprintf(os.Stdout, "%s%s\n", ui.RightPadTrim("JIRA URL", configKeyMaxLen), *poCfg.Jira.JiraURL) - fmt.Fprintf(os.Stdout, "%s%s\n", ui.RightPadTrim("JIRA Token", configKeyMaxLen), *poCfg.Jira.JiraToken) - fmt.Fprintf(os.Stdout, "%s%s\n", ui.RightPadTrim("JQL", configKeyMaxLen), *poCfg.Jira.Jql) - fmt.Fprintf(os.Stdout, "%s%d\n", ui.RightPadTrim("JIRA Time Delta Mins", configKeyMaxLen), *poCfg.Jira.JiraTimeDeltaMins) - os.Exit(0) + if *jiraTimeDeltaMinsStr != "" { + cfg.Jira.JiraTimeDeltaMins = jiraTimeDeltaMins } // validations - - if *poCfg.Jira.JiraURL == "" { + var installationType ui.JiraInstallationType + switch cfg.Jira.InstallationType { + case "", jiraInstallationTypeOnPremise: // "" to maintain backwards compatibility + installationType = ui.OnPremiseInstallation + cfg.Jira.InstallationType = jiraInstallationTypeOnPremise + case jiraInstallationTypeCloud: + installationType = ui.CloudInstallation + default: + die("invalid value for jira installation type (allowed values: [%s, %s]): %q", jiraInstallationTypeOnPremise, jiraInstallationTypeCloud, cfg.Jira.InstallationType) + } + + if cfg.Jira.JiraURL == nil || *cfg.Jira.JiraURL == "" { die("jira-url cannot be empty") } - if *poCfg.Jira.JiraToken == "" { + if cfg.Jira.Jql == nil || *cfg.Jira.Jql == "" { + die("jql cannot be empty") + } + + if cfg.Jira.JiraToken == nil || *cfg.Jira.JiraToken == "" { die("jira-token cannot be empty") } - if *poCfg.Jira.Jql == "" { - die("jql cannot be empty") + if installationType == ui.CloudInstallation && (cfg.Jira.JiraUsername == nil || *cfg.Jira.JiraUsername == "") { + die("jira-username cannot be empty for installation type \"cloud\"") + } + + configKeyMaxLen := 40 + if *listConfig { + fmt.Fprint(os.Stdout, "Config:\n\n") + fmt.Fprintf(os.Stdout, "%s%s\n", ui.RightPadTrim("Config File Path", configKeyMaxLen), *configFilePath) + fmt.Fprintf(os.Stdout, "%s%s\n", ui.RightPadTrim("DB File Path", configKeyMaxLen), dbPathFull) + fmt.Fprintf(os.Stdout, "%s%s\n", ui.RightPadTrim("JIRA Installation Type", configKeyMaxLen), cfg.Jira.InstallationType) + fmt.Fprintf(os.Stdout, "%s%s\n", ui.RightPadTrim("JIRA URL", configKeyMaxLen), *cfg.Jira.JiraURL) + fmt.Fprintf(os.Stdout, "%s%s\n", ui.RightPadTrim("JIRA Token", configKeyMaxLen), *cfg.Jira.JiraToken) + if installationType == ui.CloudInstallation { + fmt.Fprintf(os.Stdout, "%s%s\n", ui.RightPadTrim("JIRA Username", configKeyMaxLen), *cfg.Jira.JiraUsername) + } + fmt.Fprintf(os.Stdout, "%s%s\n", ui.RightPadTrim("JQL", configKeyMaxLen), *cfg.Jira.Jql) + fmt.Fprintf(os.Stdout, "%s%d\n", ui.RightPadTrim("JIRA Time Delta Mins", configKeyMaxLen), cfg.Jira.JiraTimeDeltaMins) + os.Exit(0) } db, err := setupDB(dbPathFull) if err != nil { - fmt.Fprintf(os.Stderr, "Couldn't set up punchout database. This is a fatal error\n") - os.Exit(1) + die("couldn't set up punchout database. This is a fatal error\n") } - tp := jira.BearerAuthTransport{ - Token: *poCfg.Jira.JiraToken, + var httpClient *http.Client + switch installationType { + case ui.OnPremiseInstallation: + tp := jiraOnPremise.BearerAuthTransport{ + Token: *cfg.Jira.JiraToken, + } + httpClient = tp.Client() + case ui.CloudInstallation: + tp := jiraCloud.BasicAuthTransport{ + Username: *cfg.Jira.JiraUsername, + APIToken: *cfg.Jira.JiraToken, + } + httpClient = tp.Client() } - cl, err := jira.NewClient(*poCfg.Jira.JiraURL, tp.Client()) + + // Using the on-premise client regardless of the user's installation type + // The APIs between the two installation types seem to differ, but this + // seems to be alright for punchout's use case. If this situation changes, + // this will need to be refactored. + // https://github.com/andygrunwald/go-jira/issues/473 + cl, err := jiraOnPremise.NewClient(*cfg.Jira.JiraURL, httpClient) if err != nil { - panic(err) + die("couldn't create JIRA client: %s", err) } - ui.RenderUI(db, cl, *poCfg.Jira.Jql, *poCfg.Jira.JiraTimeDeltaMins) - + ui.RenderUI(db, cl, installationType, *cfg.Jira.Jql, cfg.Jira.JiraTimeDeltaMins) } diff --git a/internal/ui/cmds.go b/internal/ui/cmds.go index aa7ae53..9e2e861 100644 --- a/internal/ui/cmds.go +++ b/internal/ui/cmds.go @@ -56,7 +56,7 @@ func insertManualEntry(db *sql.DB, issueKey string, beginTS time.Time, endTS tim stmt, err := db.Prepare(` INSERT INTO issue_log (issue_key, begin_ts, end_ts, comment, active, synced) VALUES (?, ?, ?, ?, ?, ?); - `) +`) if err != nil { return manualEntryInserted{issueKey, err} } @@ -86,7 +86,7 @@ SET begin_ts = ?, end_ts = ?, comment = ? WHERE ID = ?; - `) +`) if err != nil { return manualEntryUpdated{rowID, issueKey, err} } @@ -166,15 +166,19 @@ func updateSyncStatusForEntry(db *sql.DB, entry worklogEntry, index int) tea.Cmd func fetchJIRAIssues(cl *jira.Client, jql string) tea.Cmd { return func() tea.Msg { - jIssues, err := getIssues(cl, jql) + jIssues, statusCode, err := getIssues(cl, jql) var issues []Issue + if err != nil { + return issuesFetchedFromJIRAMsg{issues, statusCode, err} + } + for _, issue := range jIssues { var assignee string var totalSecsSpent int var status string if issue.Fields != nil { if issue.Fields.Assignee != nil { - assignee = issue.Fields.Assignee.Name + assignee = issue.Fields.Assignee.DisplayName } totalSecsSpent = issue.Fields.AggregateTimeSpent @@ -193,7 +197,7 @@ func fetchJIRAIssues(cl *jira.Client, jql string) tea.Cmd { trackingActive: false, }) } - return issuesFetchedFromJIRAMsg{issues, err} + return issuesFetchedFromJIRAMsg{issues, statusCode, nil} } } diff --git a/internal/ui/help.go b/internal/ui/help.go index 725a355..d809a7a 100644 --- a/internal/ui/help.go +++ b/internal/ui/help.go @@ -45,8 +45,8 @@ var ( `), helpHeaderStyle.Render("General List Controls"), helpSectionStyle.Render(` - h/ Move cursor up - k/ Move cursor down + k/ Move cursor up + j/ Move cursor down h Go to previous page l Go to next page / Start filtering diff --git a/internal/ui/initial.go b/internal/ui/initial.go index 49c0fb7..36f573b 100644 --- a/internal/ui/initial.go +++ b/internal/ui/initial.go @@ -9,7 +9,7 @@ import ( "github.com/charmbracelet/lipgloss" ) -func InitialModel(db *sql.DB, jiraClient *jira.Client, jql string, jiraTimeDeltaMins int, debug bool) model { +func InitialModel(db *sql.DB, jiraClient *jira.Client, installationType JiraInstallationType, jql string, jiraTimeDeltaMins int, debug bool) model { var stackItems []list.Item var worklogListItems []list.Item var syncedWorklogListItems []list.Item @@ -36,6 +36,7 @@ func InitialModel(db *sql.DB, jiraClient *jira.Client, jql string, jiraTimeDelta m := model{ db: db, jiraClient: jiraClient, + installationType: installationType, jql: jql, issueList: list.New(stackItems, newItemDelegate(lipgloss.Color(issueListColor)), listWidth, 0), issueMap: make(map[string]*Issue), diff --git a/internal/ui/jira.go b/internal/ui/jira.go index c0cc823..e083715 100644 --- a/internal/ui/jira.go +++ b/internal/ui/jira.go @@ -8,13 +8,11 @@ import ( jira "github.com/andygrunwald/go-jira/v2/onpremise" ) -var ( - jiraRepliedWithEmptyWorklogErr = errors.New("JIRA replied with an empty worklog; something is probably wrong") -) +var jiraRepliedWithEmptyWorklogErr = errors.New("JIRA replied with an empty worklog; something is probably wrong") -func getIssues(cl *jira.Client, jql string) ([]jira.Issue, error) { - issues, _, err := cl.Issue.Search(context.Background(), jql, nil) - return issues, err +func getIssues(cl *jira.Client, jql string) ([]jira.Issue, int, error) { + issues, resp, err := cl.Issue.Search(context.Background(), jql, nil) + return issues, resp.StatusCode, err } func addWLtoJira(cl *jira.Client, entry worklogEntry, timeDeltaMins int) error { diff --git a/internal/ui/model.go b/internal/ui/model.go index caf66a7..a17847f 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -11,6 +11,13 @@ import ( tea "github.com/charmbracelet/bubbletea" ) +type JiraInstallationType uint + +const ( + OnPremiseInstallation JiraInstallationType = iota + CloudInstallation +) + type trackingStatus uint const ( @@ -63,6 +70,7 @@ type model struct { lastView stateView db *sql.DB jiraClient *jira.Client + installationType JiraInstallationType jql string issueList list.Model issueMap map[string]*Issue diff --git a/internal/ui/msgs.go b/internal/ui/msgs.go index 55ad2bb..323fee4 100644 --- a/internal/ui/msgs.go +++ b/internal/ui/msgs.go @@ -52,8 +52,9 @@ type logEntrySyncUpdated struct { } type issuesFetchedFromJIRAMsg struct { - issues []Issue - err error + issues []Issue + responseStatusCode int + err error } type wlAddedOnJIRA struct { diff --git a/internal/ui/render_helpers.go b/internal/ui/render_helpers.go index 0c43774..3a1f2c4 100644 --- a/internal/ui/render_helpers.go +++ b/internal/ui/render_helpers.go @@ -11,7 +11,7 @@ func (issue *Issue) setDesc() { issueType := getIssueTypeStyle(issue.issueType).Render(issue.issueType) if issue.assignee != "" { - assignee = assigneeStyle(issue.assignee).Render(RightPadTrim("@"+issue.assignee, int(listWidth/4))) + assignee = assigneeStyle(issue.assignee).Render(RightPadTrim(issue.assignee, int(listWidth/4))) } else { assignee = assigneeStyle(issue.assignee).Render(RightPadTrim("", int(listWidth/4))) } diff --git a/internal/ui/styles.go b/internal/ui/styles.go index 901421d..c054bcf 100644 --- a/internal/ui/styles.go +++ b/internal/ui/styles.go @@ -1,13 +1,15 @@ package ui import ( - "github.com/charmbracelet/lipgloss" "hash/fnv" + + "github.com/charmbracelet/lipgloss" ) const ( defaultBackgroundColor = "#282828" issueListUnfetchedColor = "#928374" + failureColor = "#fb4934" issueListColor = "#fe8019" worklogListColor = "#fabd2f" syncedWorklogListColor = "#b8bb26" diff --git a/internal/ui/ui.go b/internal/ui/ui.go index 85bfab9..67a85f4 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -9,7 +9,7 @@ import ( tea "github.com/charmbracelet/bubbletea" ) -func RenderUI(db *sql.DB, jiraClient *jira.Client, jql string, jiraTimeDeltaMins int) { +func RenderUI(db *sql.DB, jiraClient *jira.Client, installationType JiraInstallationType, jql string, jiraTimeDeltaMins int) { if len(os.Getenv("DEBUG_LOG")) > 0 { f, err := tea.LogToFile("debug.log", "debug") if err != nil { @@ -20,7 +20,7 @@ func RenderUI(db *sql.DB, jiraClient *jira.Client, jql string, jiraTimeDeltaMins } debug := os.Getenv("DEBUG") == "true" - p := tea.NewProgram(InitialModel(db, jiraClient, jql, jiraTimeDeltaMins, debug), tea.WithAltScreen()) + p := tea.NewProgram(InitialModel(db, jiraClient, installationType, jql, jiraTimeDeltaMins, debug), tea.WithAltScreen()) if _, err := p.Run(); err != nil { fmt.Printf("Alas, there has been an error: %v", err) os.Exit(1) diff --git a/internal/ui/update.go b/internal/ui/update.go index 5b9c177..3f9b788 100644 --- a/internal/ui/update.go +++ b/internal/ui/update.go @@ -439,9 +439,21 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case issuesFetchedFromJIRAMsg: if msg.err != nil { - message := "error fetching issues from JIRA: " + msg.err.Error() - m.message = message - m.messages = append(m.messages, message) + var remoteServerName string + if msg.responseStatusCode >= 400 && msg.responseStatusCode < 500 { + switch m.installationType { + case OnPremiseInstallation: + remoteServerName = "Your on-premise JIRA installation" + case CloudInstallation: + remoteServerName = "Atlassian Cloud" + } + m.message = fmt.Sprintf("%s returned a %d status code, check if your configuration is correct", remoteServerName, msg.responseStatusCode) + } else { + m.message = fmt.Sprintf("error fetching issues from JIRA: %s", msg.err.Error()) + } + m.messages = append(m.messages, m.message) + m.issueList.Title = "Failure" + m.issueList.Styles.Title = m.issueList.Styles.Title.Background(lipgloss.Color(failureColor)) } else { issues := make([]list.Item, 0, len(msg.issues)) for i, issue := range msg.issues { diff --git a/internal/ui/utils.go b/internal/ui/utils.go index 218042c..12e19fd 100644 --- a/internal/ui/utils.go +++ b/internal/ui/utils.go @@ -33,7 +33,7 @@ func insertNewEntry(db *sql.DB, issueKey string, beginTs time.Time) error { stmt, err := db.Prepare(` INSERT INTO issue_log (issue_key, begin_ts, active, synced) VALUES (?, ?, ?, ?); - `) +`) if err != nil { return err @@ -81,7 +81,7 @@ SELECT ID, issue_key, begin_ts, end_ts, comment, active, synced FROM issue_log WHERE active=false AND synced=false ORDER by end_ts DESC; - `) +`) if err != nil { return nil, err } @@ -115,7 +115,7 @@ SELECT ID, issue_key, begin_ts, end_ts, comment FROM issue_log WHERE active=false AND synced=true ORDER by end_ts DESC LIMIT 30; - `) +`) if err != nil { return nil, err } diff --git a/tests/config-bad.toml b/tests/config-bad.toml new file mode 100644 index 0000000..7bf1dbe --- /dev/null +++ b/tests/config-bad.toml @@ -0,0 +1,2 @@ +[jira] +jql "project = SCRUM AND sprint in openSprints () ORDER BY updated DESC" diff --git a/tests/config-good.toml b/tests/config-good.toml new file mode 100644 index 0000000..cf78f8f --- /dev/null +++ b/tests/config-good.toml @@ -0,0 +1,2 @@ +[jira] +jql = "project = SCRUM AND sprint in openSprints () ORDER BY updated DESC" diff --git a/tests/test.sh b/tests/test.sh new file mode 100755 index 0000000..e640abc --- /dev/null +++ b/tests/test.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash + +cat < $title" + echo "$cmd" + echo + eval "$cmd" + exit_code=$? + if [ $exit_code -eq $expected_exit_code ]; then + echo "✅ command behaves as expected" + ((pass_count++)) + else + echo "❌ command returned $exit_code, expected $expected_exit_code" + ((fail_count++)) + fi + echo + echo "===============================" + echo +done + +echo "Summary:" +echo "- Passed: $pass_count" +echo "- Failed: $fail_count" + +if [ $fail_count -gt 0 ]; then + exit 1 +else + exit 0 +fi