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 @@
-💾 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