Skip to content

Commit

Permalink
feat: add support for cloud installations (#24)
Browse files Browse the repository at this point in the history
Co-authored-by: Dhruv Thakur <[email protected]>
  • Loading branch information
xederro and dhth authored Jan 12, 2025
1 parent b9bbf1b commit afbdaef
Show file tree
Hide file tree
Showing 18 changed files with 265 additions and 84 deletions.
32 changes: 32 additions & 0 deletions .github/workflows/e2e-tests.yml
Original file line number Diff line number Diff line change
@@ -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
45 changes: 28 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
<img src="https://tools.dhruvs.space/images/punchout/punchout.gif" alt="Usage" />
</p>

💾 Install
💾 Installation
---

**homebrew**:
Expand All @@ -33,44 +33,55 @@ 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 = "[email protected]"

# 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='[email protected]' \
-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
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
Expand Down Expand Up @@ -114,8 +125,8 @@ General
General List Controls
h/<Up> Move cursor up
k/<Down> Move cursor down
k/<Up> Move cursor up
j/<Down> Move cursor down
h<Left> Go to previous page
l<Right> Go to next page
/ Start filtering
Expand Down
13 changes: 9 additions & 4 deletions cmd/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,25 +4,30 @@ 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 {
Jira JiraConfig
}

func readConfig(filePath string) (POConfig, error) {

var config POConfig
_, err := toml.DecodeFile(expandTilde(filePath), &config)
if err != nil {
return config, err
}

return config, nil

}
119 changes: 82 additions & 37 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand All @@ -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")
}
Expand Down Expand Up @@ -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)
}
14 changes: 9 additions & 5 deletions internal/ui/cmds.go
Original file line number Diff line number Diff line change
Expand Up @@ -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}
}
Expand Down Expand Up @@ -86,7 +86,7 @@ SET begin_ts = ?,
end_ts = ?,
comment = ?
WHERE ID = ?;
`)
`)
if err != nil {
return manualEntryUpdated{rowID, issueKey, err}
}
Expand Down Expand Up @@ -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
Expand All @@ -193,7 +197,7 @@ func fetchJIRAIssues(cl *jira.Client, jql string) tea.Cmd {
trackingActive: false,
})
}
return issuesFetchedFromJIRAMsg{issues, err}
return issuesFetchedFromJIRAMsg{issues, statusCode, nil}
}
}

Expand Down
4 changes: 2 additions & 2 deletions internal/ui/help.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,8 @@ var (
`),
helpHeaderStyle.Render("General List Controls"),
helpSectionStyle.Render(`
h/<Up> Move cursor up
k/<Down> Move cursor down
k/<Up> Move cursor up
j/<Down> Move cursor down
h<Left> Go to previous page
l<Right> Go to next page
/ Start filtering
Expand Down
Loading

0 comments on commit afbdaef

Please sign in to comment.