├── .github ├── dependabot.yml └── workflows │ ├── build.yml │ ├── lint.yml │ ├── release.yml │ ├── test.yml │ └── vulncheck.yml ├── .gitignore ├── .golangci.yml ├── .goreleaser.yaml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── cmd ├── config.go ├── root.go └── utils.go ├── go.mod ├── go.sum ├── internal ├── common │ ├── styles.go │ ├── types.go │ └── utils.go ├── persistence │ ├── init.go │ ├── open.go │ ├── queries.go │ └── queries_test.go └── ui │ ├── cmds.go │ ├── date_helpers.go │ ├── handle.go │ ├── help.go │ ├── initial.go │ ├── issue_delegate.go │ ├── jira.go │ ├── model.go │ ├── msgs.go │ ├── styles.go │ ├── ui.go │ ├── update.go │ └── view.go ├── punch.go ├── punchout.gif └── tests ├── config-bad.toml ├── config-good.toml └── test.sh /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "gomod" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | reviewers: 8 | - "dhth" 9 | labels: 10 | - "dependencies" 11 | commit-message: 12 | prefix: "chore" 13 | include: "scope" 14 | - package-ecosystem: "github-actions" 15 | directory: "/" 16 | schedule: 17 | interval: "weekly" 18 | reviewers: 19 | - "dhth" 20 | labels: 21 | - "dependencies" 22 | commit-message: 23 | prefix: "chore" 24 | include: "scope" 25 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | push: 5 | branches: ["main"] 6 | pull_request: 7 | paths: 8 | - "go.*" 9 | - "**/*.go" 10 | - ".github/workflows/build.yml" 11 | 12 | permissions: 13 | contents: read 14 | 15 | env: 16 | GO_VERSION: '1.24.3' 17 | 18 | jobs: 19 | build: 20 | strategy: 21 | matrix: 22 | os: [ubuntu-latest, macos-latest] 23 | runs-on: ${{ matrix.os }} 24 | steps: 25 | - uses: actions/checkout@v4 26 | - name: Set up Go 27 | uses: actions/setup-go@v5 28 | with: 29 | go-version: ${{ env.GO_VERSION }} 30 | - name: go build 31 | run: go build -v ./... 32 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: lint 2 | 3 | on: 4 | push: 5 | branches: ["main"] 6 | pull_request: 7 | paths: 8 | - "go.*" 9 | - "**/*.go" 10 | - ".github/workflows/lint.yml" 11 | 12 | permissions: 13 | contents: read 14 | 15 | env: 16 | GO_VERSION: '1.24.3' 17 | 18 | jobs: 19 | lint: 20 | runs-on: ubuntu-latest 21 | steps: 22 | - uses: actions/checkout@v4 23 | - name: Set up Go 24 | uses: actions/setup-go@v5 25 | with: 26 | go-version: ${{ env.GO_VERSION }} 27 | - name: golangci-lint 28 | uses: golangci/golangci-lint-action@v8 29 | with: 30 | version: v2.1 31 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | permissions: 9 | id-token: write 10 | 11 | env: 12 | GO_VERSION: '1.24.3' 13 | 14 | jobs: 15 | release: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v4 19 | with: 20 | fetch-depth: 0 21 | - name: Set up Go 22 | uses: actions/setup-go@v5 23 | with: 24 | go-version: ${{ env.GO_VERSION }} 25 | - name: Build 26 | run: go build -v ./... 27 | - name: Install Cosign 28 | uses: sigstore/cosign-installer@v3 29 | with: 30 | cosign-release: 'v2.5.0' 31 | - name: Release Binaries 32 | uses: goreleaser/goreleaser-action@v6 33 | with: 34 | version: '~> v2' 35 | args: release --clean 36 | env: 37 | GITHUB_TOKEN: ${{secrets.GH_PAT}} 38 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: 4 | push: 5 | branches: ["main"] 6 | pull_request: 7 | paths: 8 | - "go.*" 9 | - "**/*.go" 10 | - ".github/workflows/test.yml" 11 | 12 | env: 13 | GO_VERSION: '1.24.3' 14 | 15 | jobs: 16 | test: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v4 20 | - name: Set up Go 21 | uses: actions/setup-go@v5 22 | with: 23 | go-version: ${{ env.GO_VERSION }} 24 | - name: Run tests 25 | run: go test ./... -v 26 | 27 | live: 28 | needs: [test] 29 | strategy: 30 | matrix: 31 | os: [ubuntu-latest, macos-latest] 32 | runs-on: ${{ matrix.os }} 33 | steps: 34 | - uses: actions/checkout@v4 35 | - name: Set up Go 36 | uses: actions/setup-go@v5 37 | with: 38 | go-version: ${{ env.GO_VERSION }} 39 | - name: Install 40 | run: go install . 41 | - name: Run live tests 42 | run: | 43 | cd tests 44 | ./test.sh 45 | -------------------------------------------------------------------------------- /.github/workflows/vulncheck.yml: -------------------------------------------------------------------------------- 1 | name: vulncheck 2 | on: 3 | push: 4 | branches: ["main"] 5 | pull_request: 6 | paths: 7 | - "go.*" 8 | - "**/*.go" 9 | - ".github/workflows/vulncheck.yml" 10 | 11 | permissions: 12 | contents: read 13 | 14 | env: 15 | GO_VERSION: '1.24.3' 16 | 17 | jobs: 18 | vulncheck: 19 | name: vulncheck 20 | runs-on: ubuntu-latest 21 | steps: 22 | - uses: actions/checkout@v4 23 | - name: Set up Go 24 | uses: actions/setup-go@v5 25 | with: 26 | go-version: ${{ env.GO_VERSION }} 27 | - name: govulncheck 28 | shell: bash 29 | run: | 30 | go install golang.org/x/vuln/cmd/govulncheck@latest 31 | govulncheck ./... 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | cosign.key 3 | cosign.pub 4 | punchout 5 | debug.log 6 | punchout.v*.db 7 | .quickrun 8 | justfile 9 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | linters: 3 | enable: 4 | - errname 5 | - errorlint 6 | - goconst 7 | - nilerr 8 | - prealloc 9 | - predeclared 10 | - revive 11 | - rowserrcheck 12 | - sqlclosecheck 13 | - testifylint 14 | - thelper 15 | - unconvert 16 | - usestdlibvars 17 | - wastedassign 18 | settings: 19 | revive: 20 | rules: 21 | - name: blank-imports 22 | - name: context-as-argument 23 | arguments: 24 | - allowTypesBefore: '*testing.T' 25 | - name: context-keys-type 26 | - name: dot-imports 27 | - name: empty-block 28 | - name: error-naming 29 | - name: error-return 30 | - name: error-strings 31 | - name: errorf 32 | - name: exported 33 | - name: if-return 34 | - name: increment-decrement 35 | - name: indent-error-flow 36 | - name: package-comments 37 | - name: range 38 | - name: receiver-naming 39 | - name: redefines-builtin-id 40 | - name: superfluous-else 41 | - name: time-naming 42 | - name: unexported-return 43 | - name: unreachable-code 44 | - name: unused-parameter 45 | - name: var-declaration 46 | - name: var-naming 47 | - name: deep-exit 48 | - name: confusing-naming 49 | - name: unused-receiver 50 | - name: unhandled-error 51 | arguments: 52 | - fmt.Print 53 | - fmt.Printf 54 | - fmt.Fprintf 55 | - fmt.Fprint 56 | exclusions: 57 | generated: lax 58 | presets: 59 | - comments 60 | - common-false-positives 61 | - legacy 62 | - std-error-handling 63 | paths: 64 | - third_party$ 65 | - builtin$ 66 | - examples$ 67 | formatters: 68 | enable: 69 | - gofumpt 70 | exclusions: 71 | generated: lax 72 | paths: 73 | - third_party$ 74 | - builtin$ 75 | - examples$ 76 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | release: 4 | draft: true 5 | 6 | before: 7 | hooks: 8 | - go mod tidy 9 | - go generate ./... 10 | 11 | builds: 12 | - env: 13 | - CGO_ENABLED=0 14 | goos: 15 | - linux 16 | - darwin 17 | 18 | signs: 19 | - cmd: cosign 20 | signature: "${artifact}.sig" 21 | certificate: "${artifact}.pem" 22 | args: 23 | - "sign-blob" 24 | - "--oidc-issuer=https://token.actions.githubusercontent.com" 25 | - "--output-certificate=${certificate}" 26 | - "--output-signature=${signature}" 27 | - "${artifact}" 28 | - "--yes" 29 | artifacts: checksum 30 | 31 | brews: 32 | - name: punchout 33 | repository: 34 | owner: dhth 35 | name: homebrew-tap 36 | directory: Formula 37 | license: MIT 38 | homepage: "https://github.com/dhth/punchout" 39 | description: "punchout takes the suck out of logging time on JIRA" 40 | 41 | changelog: 42 | sort: asc 43 | filters: 44 | exclude: 45 | - "^docs:" 46 | - "^test:" 47 | - "^ci:" 48 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [Unreleased] 9 | 10 | ### Added 11 | 12 | - Worklog entry/update view: allow syncing worklog timestamps with each other 13 | 14 | ## [v1.2.0] - Jan 16, 2025 15 | 16 | ### Added 17 | 18 | - Allow for quickly switching actively tracked issue 19 | - Add support for fallback comments 20 | - Allow updating active worklog entry 21 | - Add support for JIRA Cloud installation 22 | - Allow shifting timestamps for worklog entries using h/j/k/l/J/K 23 | - Show time spent on unsynced worklog entries 24 | 25 | ### Changed 26 | 27 | - Save UTC timestamps in the database 28 | - Allow going back views instead of quitting directly 29 | - Improved error handling 30 | - Upgrade to go 1.23.4 31 | - Dependency upgrades 32 | 33 | ## [v1.1.0] - Jul 2, 2024 34 | 35 | ### Added 36 | 37 | - Allow tweaking time when saving worklog 38 | - Add first time help, "tracking started since" indicator 39 | - Show indicator for currently tracked item 40 | - Show unsynced count 41 | - Add more colors for issue type 42 | - Dependency upgrades 43 | 44 | [unreleased]: https://github.com/dhth/punchout/compare/v1.2.0...HEAD 45 | [v1.2.0]: https://github.com/dhth/punchout/compare/v1.1.0...v1.2.0 46 | [v1.1.0]: https://github.com/dhth/punchout/compare/v1.0.0...v1.1.0 47 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Dhruv Thakur 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # punchout 2 | 3 | ✨ Overview 4 | --- 5 | 6 | `punchout` takes the suck out of logging time on JIRA. 7 | 8 |

9 | Usage 10 |

11 | 12 | 💾 Installation 13 | --- 14 | 15 | **homebrew**: 16 | 17 | ```sh 18 | brew install dhth/tap/punchout 19 | ``` 20 | 21 | **go**: 22 | 23 | ```sh 24 | go install github.com/dhth/punchout@latest 25 | ``` 26 | 27 | ⚡️ Usage 28 | --- 29 | 30 | `punchout` can receive its configuration via command line flags, or a config 31 | file. 32 | 33 | ### Using a config file 34 | 35 | Create a toml file that looks like the following. The default location for this 36 | file is `~/.config/punchout/punchout.toml`. The configuration needed for 37 | authenticating against your JIRA installation (on-premise or cloud) will depend 38 | on the kind of the installation. 39 | 40 | ```toml 41 | [jira] 42 | jira_url = "https://jira.company.com" 43 | 44 | # for on-premise installations 45 | installation_type = "onpremise" 46 | jira_token = "your personal access token" 47 | 48 | # for cloud installations 49 | installation_type = "cloud" 50 | jira_token = "your API token" 51 | jira_username = "example@example.com" 52 | 53 | # put whatever JQL you want to query for 54 | jql = "assignee = currentUser() AND updatedDate >= -14d ORDER BY updatedDate DESC" 55 | 56 | # I don't know how many people will find use for this. 57 | # I need this, since the JIRA on-premise server I use runs 5 hours behind 58 | # the actual time, for whatever reason 🤷 59 | jira_time_delta_mins = 300 60 | 61 | # this comment will be used for worklogs when you don't provide one; optional" 62 | fallback_comment = "comment" 63 | ``` 64 | 65 | ### Basic usage 66 | 67 | Use `punchout -h` for help. 68 | 69 | ```bash 70 | punchout \ 71 | -db-path='/path/to/punchout/db/file.db' \ 72 | -jira-url='https://jira.company.com' \ 73 | -jira-installation-type 'onpremise' \ 74 | -jira-token='XXX' \ 75 | -jql='assignee = currentUser() AND updatedDate >= -14d ORDER BY updatedDate DESC' 76 | 77 | punchout \ 78 | -db-path='/path/to/punchout/db/file.db' \ 79 | -jira-url='https://jira.company.com' \ 80 | -jira-installation-type 'cloud' \ 81 | -jira-token='XXX' \ 82 | -jira-username='example@example.com' \ 83 | -jql='assignee = currentUser() AND updatedDate >= -14d ORDER BY updatedDate DESC' 84 | ``` 85 | 86 | Both the config file and the command line flags can be used in conjunction, but 87 | the latter will take precedence over the former. 88 | 89 | ```bash 90 | punchout \ 91 | -config-file-path='/path/to/punchout/config/file.toml' \ 92 | -jira-token='XXX' \ 93 | -jql='assignee = currentUser() AND updatedDate >= -14d ORDER BY updatedDate DESC' 94 | ``` 95 | 96 | 🖥️ Screenshots 97 | --- 98 | 99 |

100 | Usage 101 |

102 |

103 | Usage 104 |

105 |

106 | Usage 107 |

108 | 109 | 📋 Reference Manual 110 | --- 111 | 112 | ``` 113 | punchout Reference Manual 114 | 115 | punchout has 5 panes: 116 | - Issues List View Shows you issues matching your JQL query 117 | - Worklog List View Shows you your worklog entries; you sync these entries 118 | to JIRA from here 119 | - Worklog Entry View You enter/update a worklog entry from here 120 | - Synced Worklog Entry View You view the worklog entries synced to JIRA 121 | 122 | - Help View (this one) 123 | 124 | Keyboard Shortcuts 125 | 126 | General 127 | 128 | 1 Switch to Issues List View 129 | 2 Switch to Worklog List View 130 | 3 Switch to Synced Worklog List View 131 | Go to next view/form entry 132 | Go to previous view/form entry 133 | q/ Go back/reset filtering/quit 134 | Cancel form/quit 135 | ? Show help view 136 | 137 | General List Controls 138 | 139 | k/ Move cursor up 140 | j/ Move cursor down 141 | h Go to previous page 142 | l Go to next page 143 | / Start filtering 144 | 145 | Issue List View 146 | 147 | s Toggle recording time on the currently selected issue, 148 | will open up a form to record a comment on the second 149 | "s" keypress 150 | S Quick switch recording; will save a worklog entry without 151 | a comment for the currently active issue, and start 152 | recording time for another issue 153 | Update active worklog entry (when tracking active), or add 154 | manual worklog entry (when not tracking) 155 | Go to currently tracked item 156 | Discard currently active recording 157 | Open issue in browser 158 | 159 | Worklog List View 160 | 161 | /u Update worklog entry 162 | Delete worklog entry 163 | s Sync all visible entries to JIRA 164 | Refresh list 165 | 166 | Worklog Entry View 167 | 168 | enter Save worklog entry 169 | k Move timestamp backwards by one minute 170 | j Move timestamp forwards by one minute 171 | K Move timestamp backwards by five minutes 172 | J Move timestamp forwards by five minutes 173 | h Move timestamp backwards by a day 174 | l Move timestamp forwards by a day 175 | 176 | Synced Worklog Entry View 177 | 178 | Refresh list 179 | 180 | ``` 181 | 182 | Acknowledgements 183 | --- 184 | 185 | `punchout` is built using the awesome TUI framework [bubbletea][1]. 186 | 187 | [1]: https://github.com/charmbracelet/bubbletea 188 | [2]: https://community.atlassian.com/t5/Atlassian-Migration-Program/Product-features-comparison-Atlassian-Cloud-vs-on-premise/ba-p/1918147 189 | -------------------------------------------------------------------------------- /cmd/config.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/BurntSushi/toml" 5 | ) 6 | 7 | const ( 8 | jiraInstallationTypeOnPremise = "onpremise" 9 | jiraInstallationTypeCloud = "cloud" 10 | ) 11 | 12 | type JiraConfig struct { 13 | InstallationType string `toml:"installation_type"` 14 | JiraURL *string `toml:"jira_url"` 15 | JQL *string 16 | JiraTimeDeltaMins int `toml:"jira_time_delta_mins"` 17 | JiraToken *string `toml:"jira_token"` 18 | JiraUsername *string `toml:"jira_username"` 19 | FallbackComment *string `toml:"fallback_comment"` 20 | } 21 | 22 | type POConfig struct { 23 | Jira JiraConfig 24 | } 25 | 26 | func getConfig(filePath string) (POConfig, error) { 27 | var config POConfig 28 | _, err := toml.DecodeFile(filePath, &config) 29 | if err != nil { 30 | return config, err 31 | } 32 | 33 | return config, nil 34 | } 35 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "errors" 5 | "flag" 6 | "fmt" 7 | "net/http" 8 | "os" 9 | "path/filepath" 10 | "runtime" 11 | "strconv" 12 | "strings" 13 | 14 | jiraCloud "github.com/andygrunwald/go-jira/v2/cloud" 15 | jiraOnPremise "github.com/andygrunwald/go-jira/v2/onpremise" 16 | c "github.com/dhth/punchout/internal/common" 17 | pers "github.com/dhth/punchout/internal/persistence" 18 | "github.com/dhth/punchout/internal/ui" 19 | ) 20 | 21 | const ( 22 | configFileName = "punchout/punchout.toml" 23 | ) 24 | 25 | var ( 26 | dbFileName = fmt.Sprintf("punchout.v%s.db", pers.DBVersion) 27 | jiraInstallationType = flag.String("jira-installation-type", "", "JIRA installation type; allowed values: [cloud, onpremise]") 28 | jiraURL = flag.String("jira-url", "", "URL of the JIRA server") 29 | jiraToken = flag.String("jira-token", "", "jira token (PAT for on-premise installation, API token for cloud installation)") 30 | jiraUsername = flag.String("jira-username", "", "username for authentication") 31 | jql = flag.String("jql", "", "JQL to use to query issues") 32 | fallbackComment = flag.String("fallback-comment", "", "Fallback comment to use for worklog entries") 33 | jiraTimeDeltaMinsStr = flag.String("jira-time-delta-mins", "", "Time delta (in minutes) between your timezone and the timezone of the server; can be +/-") 34 | listConfig = flag.Bool("list-config", false, "print the config that punchout will use") 35 | ) 36 | 37 | var ( 38 | errCouldntGetHomeDir = errors.New("couldn't get your home directory") 39 | errCouldntGetConfigDir = errors.New("couldn't get your default config directory") 40 | errConfigFilePathEmpty = errors.New("config file path cannot be empty") 41 | errDBPathEmpty = errors.New("db file path cannot be empty") 42 | errCouldntInitializeDB = errors.New("couldn't initialize database") 43 | errTimeDeltaIncorrect = errors.New("couldn't convert time delta to a number") 44 | errCouldntParseConfigFile = errors.New("couldn't parse config file") 45 | errInvalidInstallationType = fmt.Errorf("invalid value for jira installation type (allowed values: [%s, %s])", jiraInstallationTypeOnPremise, jiraInstallationTypeCloud) 46 | errCouldntCreateDB = errors.New("couldn't create punchout database") 47 | errCouldntCreateJiraClient = errors.New("couldn't create JIRA client") 48 | ) 49 | 50 | func Execute() error { 51 | userHomeDir, err := os.UserHomeDir() 52 | if err != nil { 53 | return fmt.Errorf("%w: %s", errCouldntGetHomeDir, err.Error()) 54 | } 55 | 56 | defaultConfigDir, err := os.UserConfigDir() 57 | if err != nil { 58 | return fmt.Errorf("%w: %s", errCouldntGetConfigDir, err.Error()) 59 | } 60 | 61 | ros := runtime.GOOS 62 | var defaultConfigFilePath string 63 | 64 | switch ros { 65 | case "darwin": 66 | // This is to maintain backwards compatibility with a decision made in the first release of punchout 67 | defaultConfigFilePath = filepath.Join(userHomeDir, ".config", configFileName) 68 | default: 69 | defaultConfigFilePath = filepath.Join(defaultConfigDir, configFileName) 70 | } 71 | 72 | configFilePath := flag.String("config-file-path", defaultConfigFilePath, "location of the punchout config file") 73 | 74 | defaultDBPath := filepath.Join(userHomeDir, dbFileName) 75 | dbPath := flag.String("db-path", defaultDBPath, "location of punchout's local database") 76 | 77 | flag.Usage = func() { 78 | fmt.Fprintf(os.Stdout, "punchout takes the suck out of logging time on JIRA.\n\nFlags:\n") 79 | flag.CommandLine.SetOutput(os.Stdout) 80 | flag.PrintDefaults() 81 | } 82 | flag.Parse() 83 | 84 | if *configFilePath == "" { 85 | return errConfigFilePathEmpty 86 | } 87 | 88 | if *dbPath == "" { 89 | return errDBPathEmpty 90 | } 91 | 92 | dbPathFull := expandTilde(*dbPath, userHomeDir) 93 | 94 | var jiraTimeDeltaMins int 95 | if *jiraTimeDeltaMinsStr != "" { 96 | jiraTimeDeltaMins, err = strconv.Atoi(*jiraTimeDeltaMinsStr) 97 | if err != nil { 98 | return fmt.Errorf("%w: %s", errTimeDeltaIncorrect, err.Error()) 99 | } 100 | } 101 | 102 | configPathFull := expandTilde(*configFilePath, userHomeDir) 103 | 104 | cfg, err := getConfig(configPathFull) 105 | if err != nil { 106 | return fmt.Errorf("%w: %s", errCouldntParseConfigFile, err.Error()) 107 | } 108 | 109 | if *jiraInstallationType != "" { 110 | cfg.Jira.InstallationType = *jiraInstallationType 111 | } 112 | 113 | if *jiraURL != "" { 114 | cfg.Jira.JiraURL = jiraURL 115 | } 116 | 117 | if *jiraToken != "" { 118 | cfg.Jira.JiraToken = jiraToken 119 | } 120 | 121 | if *jiraUsername != "" { 122 | cfg.Jira.JiraUsername = jiraUsername 123 | } 124 | 125 | if *jql != "" { 126 | cfg.Jira.JQL = jql 127 | } 128 | 129 | if *jiraTimeDeltaMinsStr != "" { 130 | cfg.Jira.JiraTimeDeltaMins = jiraTimeDeltaMins 131 | } 132 | 133 | if *fallbackComment != "" { 134 | cfg.Jira.FallbackComment = fallbackComment 135 | } 136 | 137 | // validations 138 | var installationType ui.JiraInstallationType 139 | switch cfg.Jira.InstallationType { 140 | case "", jiraInstallationTypeOnPremise: // "" to maintain backwards compatibility 141 | installationType = ui.OnPremiseInstallation 142 | cfg.Jira.InstallationType = jiraInstallationTypeOnPremise 143 | case jiraInstallationTypeCloud: 144 | installationType = ui.CloudInstallation 145 | default: 146 | return errInvalidInstallationType 147 | } 148 | 149 | if cfg.Jira.JiraURL == nil || *cfg.Jira.JiraURL == "" { 150 | return fmt.Errorf("jira-url cannot be empty") 151 | } 152 | 153 | if cfg.Jira.JQL == nil || *cfg.Jira.JQL == "" { 154 | return fmt.Errorf("jql cannot be empty") 155 | } 156 | 157 | if cfg.Jira.JiraToken == nil || *cfg.Jira.JiraToken == "" { 158 | return fmt.Errorf("jira-token cannot be empty") 159 | } 160 | 161 | if installationType == ui.CloudInstallation && (cfg.Jira.JiraUsername == nil || *cfg.Jira.JiraUsername == "") { 162 | return fmt.Errorf("jira-username cannot be empty for cloud installation") 163 | } 164 | 165 | if cfg.Jira.FallbackComment != nil && strings.TrimSpace(*cfg.Jira.FallbackComment) == "" { 166 | return fmt.Errorf("fallback-comment cannot be empty") 167 | } 168 | 169 | configKeyMaxLen := 40 170 | if *listConfig { 171 | fmt.Fprint(os.Stdout, "Config:\n\n") 172 | fmt.Fprintf(os.Stdout, "%s%s\n", c.RightPadTrim("Config File Path", configKeyMaxLen), configPathFull) 173 | fmt.Fprintf(os.Stdout, "%s%s\n", c.RightPadTrim("DB File Path", configKeyMaxLen), dbPathFull) 174 | fmt.Fprintf(os.Stdout, "%s%s\n", c.RightPadTrim("JIRA Installation Type", configKeyMaxLen), cfg.Jira.InstallationType) 175 | fmt.Fprintf(os.Stdout, "%s%s\n", c.RightPadTrim("JIRA URL", configKeyMaxLen), *cfg.Jira.JiraURL) 176 | fmt.Fprintf(os.Stdout, "%s%s\n", c.RightPadTrim("JIRA Token", configKeyMaxLen), *cfg.Jira.JiraToken) 177 | if installationType == ui.CloudInstallation { 178 | fmt.Fprintf(os.Stdout, "%s%s\n", c.RightPadTrim("JIRA Username", configKeyMaxLen), *cfg.Jira.JiraUsername) 179 | } 180 | fmt.Fprintf(os.Stdout, "%s%s\n", c.RightPadTrim("JQL", configKeyMaxLen), *cfg.Jira.JQL) 181 | fmt.Fprintf(os.Stdout, "%s%d\n", c.RightPadTrim("JIRA Time Delta Mins", configKeyMaxLen), cfg.Jira.JiraTimeDeltaMins) 182 | return nil 183 | } 184 | 185 | db, err := pers.GetDB(dbPathFull) 186 | if err != nil { 187 | return fmt.Errorf("%w: %s", errCouldntCreateDB, err.Error()) 188 | } 189 | 190 | err = pers.InitDB(db) 191 | if err != nil { 192 | return fmt.Errorf("%w: %s", errCouldntInitializeDB, err.Error()) 193 | } 194 | 195 | var httpClient *http.Client 196 | switch installationType { 197 | case ui.OnPremiseInstallation: 198 | tp := jiraOnPremise.BearerAuthTransport{ 199 | Token: *cfg.Jira.JiraToken, 200 | } 201 | httpClient = tp.Client() 202 | case ui.CloudInstallation: 203 | tp := jiraCloud.BasicAuthTransport{ 204 | Username: *cfg.Jira.JiraUsername, 205 | APIToken: *cfg.Jira.JiraToken, 206 | } 207 | httpClient = tp.Client() 208 | } 209 | 210 | // Using the on-premise client regardless of the user's installation type 211 | // The APIs between the two installation types seem to differ, but this 212 | // seems to be alright for punchout's use case. If this situation changes, 213 | // this will need to be refactored. 214 | // https://github.com/andygrunwald/go-jira/issues/473 215 | cl, err := jiraOnPremise.NewClient(*cfg.Jira.JiraURL, httpClient) 216 | if err != nil { 217 | return fmt.Errorf("%w: %s", errCouldntCreateJiraClient, err.Error()) 218 | } 219 | 220 | return ui.RenderUI(db, cl, installationType, *cfg.Jira.JQL, cfg.Jira.JiraTimeDeltaMins, cfg.Jira.FallbackComment) 221 | } 222 | -------------------------------------------------------------------------------- /cmd/utils.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "path/filepath" 5 | "strings" 6 | ) 7 | 8 | func expandTilde(path string, homeDir string) string { 9 | if strings.HasPrefix(path, "~/") { 10 | return filepath.Join(homeDir, path[2:]) 11 | } 12 | return path 13 | } 14 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/dhth/punchout 2 | 3 | go 1.24.3 4 | 5 | require ( 6 | github.com/BurntSushi/toml v1.5.0 7 | github.com/andygrunwald/go-jira/v2 v2.0.0-20250322171429-cfa118a2a9d4 8 | github.com/charmbracelet/bubbles v0.20.0 9 | github.com/charmbracelet/bubbletea v1.3.5 10 | github.com/charmbracelet/lipgloss v1.1.0 11 | github.com/dustin/go-humanize v1.0.1 12 | github.com/stretchr/testify v1.10.0 13 | modernc.org/sqlite v1.37.1 14 | ) 15 | 16 | require ( 17 | github.com/atotto/clipboard v0.1.4 // indirect 18 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 19 | github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect 20 | github.com/charmbracelet/x/ansi v0.8.0 // indirect 21 | github.com/charmbracelet/x/cellbuf v0.0.13 // indirect 22 | github.com/charmbracelet/x/term v0.2.1 // indirect 23 | github.com/davecgh/go-spew v1.1.1 // indirect 24 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect 25 | github.com/fatih/structs v1.1.0 // indirect 26 | github.com/golang-jwt/jwt/v4 v4.5.2 // indirect 27 | github.com/google/go-querystring v1.1.0 // indirect 28 | github.com/google/uuid v1.6.0 // indirect 29 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 30 | github.com/mattn/go-isatty v0.0.20 // indirect 31 | github.com/mattn/go-localereader v0.0.1 // indirect 32 | github.com/mattn/go-runewidth v0.0.16 // indirect 33 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect 34 | github.com/muesli/cancelreader v0.2.2 // indirect 35 | github.com/muesli/termenv v0.16.0 // indirect 36 | github.com/ncruces/go-strftime v0.1.9 // indirect 37 | github.com/pmezard/go-difflib v1.0.0 // indirect 38 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect 39 | github.com/rivo/uniseg v0.4.7 // indirect 40 | github.com/sahilm/fuzzy v0.1.1 // indirect 41 | github.com/trivago/tgo v1.0.7 // indirect 42 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 43 | golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect 44 | golang.org/x/sync v0.14.0 // indirect 45 | golang.org/x/sys v0.33.0 // indirect 46 | golang.org/x/text v0.23.0 // indirect 47 | gopkg.in/yaml.v3 v3.0.1 // indirect 48 | modernc.org/libc v1.65.7 // indirect 49 | modernc.org/mathutil v1.7.1 // indirect 50 | modernc.org/memory v1.11.0 // indirect 51 | ) 52 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= 2 | github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= 3 | github.com/andygrunwald/go-jira/v2 v2.0.0-20250322171429-cfa118a2a9d4 h1:bcQsULG7w2tjmMt7cu9pvsl9zzFMeNgLc+ZoJCykrRI= 4 | github.com/andygrunwald/go-jira/v2 v2.0.0-20250322171429-cfa118a2a9d4/go.mod h1:PmolOmLs9fDr4F240qyXuTuurFxblZiQKTztY+xAmKw= 5 | github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= 6 | github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= 7 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 8 | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 9 | github.com/charmbracelet/bubbles v0.20.0 h1:jSZu6qD8cRQ6k9OMfR1WlM+ruM8fkPWkHvQWD9LIutE= 10 | github.com/charmbracelet/bubbles v0.20.0/go.mod h1:39slydyswPy+uVOHZ5x/GjwVAFkCsV8IIVy+4MhzwwU= 11 | github.com/charmbracelet/bubbletea v1.3.5 h1:JAMNLTbqMOhSwoELIr0qyP4VidFq72/6E9j7HHmRKQc= 12 | github.com/charmbracelet/bubbletea v1.3.5/go.mod h1:TkCnmH+aBd4LrXhXcqrKiYwRs7qyQx5rBgH5fVY3v54= 13 | github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= 14 | github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= 15 | github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= 16 | github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= 17 | github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= 18 | github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= 19 | github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= 20 | github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= 21 | github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= 22 | github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= 23 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 24 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 25 | github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= 26 | github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 27 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= 28 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= 29 | github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= 30 | github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= 31 | github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= 32 | github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= 33 | github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 34 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 35 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 36 | github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= 37 | github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= 38 | github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= 39 | github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= 40 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 41 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 42 | github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= 43 | github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 44 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 45 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 46 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 47 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 48 | github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= 49 | github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= 50 | github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 51 | github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 52 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= 53 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= 54 | github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= 55 | github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 56 | github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= 57 | github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= 58 | github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= 59 | github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= 60 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 61 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 62 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= 63 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= 64 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 65 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 66 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 67 | github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= 68 | github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= 69 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 70 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 71 | github.com/trivago/tgo v1.0.7 h1:uaWH/XIy9aWYWpjm2CU3RpcqZXmX2ysQ9/Go+d9gyrM= 72 | github.com/trivago/tgo v1.0.7/go.mod h1:w4dpD+3tzNIIiIfkWWa85w5/B77tlvdZckQ+6PkFnhc= 73 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= 74 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 75 | golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= 76 | golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= 77 | golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= 78 | golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= 79 | golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= 80 | golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 81 | golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 82 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 83 | golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= 84 | golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 85 | golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= 86 | golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= 87 | golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc= 88 | golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI= 89 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 90 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 91 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 92 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 93 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 94 | modernc.org/cc/v4 v4.26.1 h1:+X5NtzVBn0KgsBCBe+xkDC7twLb/jNVj9FPgiwSQO3s= 95 | modernc.org/cc/v4 v4.26.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= 96 | modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU= 97 | modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE= 98 | modernc.org/fileutil v1.3.1 h1:8vq5fe7jdtEvoCf3Zf9Nm0Q05sH6kGx0Op2CPx1wTC8= 99 | modernc.org/fileutil v1.3.1/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= 100 | modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= 101 | modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= 102 | modernc.org/libc v1.65.7 h1:Ia9Z4yzZtWNtUIuiPuQ7Qf7kxYrxP1/jeHZzG8bFu00= 103 | modernc.org/libc v1.65.7/go.mod h1:011EQibzzio/VX3ygj1qGFt5kMjP0lHb0qCW5/D/pQU= 104 | modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= 105 | modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= 106 | modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= 107 | modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= 108 | modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= 109 | modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= 110 | modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= 111 | modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= 112 | modernc.org/sqlite v1.37.1 h1:EgHJK/FPoqC+q2YBXg7fUmES37pCHFc97sI7zSayBEs= 113 | modernc.org/sqlite v1.37.1/go.mod h1:XwdRtsE1MpiBcL54+MbKcaDvcuej+IYSMfLN6gSKV8g= 114 | modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= 115 | modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= 116 | modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= 117 | modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= 118 | -------------------------------------------------------------------------------- /internal/common/styles.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "hash/fnv" 5 | 6 | "github.com/charmbracelet/lipgloss" 7 | ) 8 | 9 | const ( 10 | DefaultBackgroundColor = "#282828" 11 | issueStatusColor = "#665c54" 12 | needsCommentColor = "#fb4934" 13 | FallbackCommentColor = "#83a598" 14 | syncedColor = "#b8bb26" 15 | syncingColor = "#fabd2f" 16 | notSyncedColor = "#928374" 17 | aggTimeSpentColor = "#928374" 18 | fallbackIssueColor = "#ada7ff" 19 | fallbackAssigneeColor = "#ccccff" 20 | ) 21 | 22 | var ( 23 | BaseStyle = lipgloss.NewStyle(). 24 | PaddingLeft(1). 25 | PaddingRight(1). 26 | Foreground(lipgloss.Color(DefaultBackgroundColor)) 27 | 28 | statusStyle = BaseStyle. 29 | Bold(true). 30 | Align(lipgloss.Center). 31 | Width(14) 32 | 33 | usingFallbackCommentStyle = statusStyle. 34 | Width(20). 35 | MarginLeft(2). 36 | Background(lipgloss.Color(FallbackCommentColor)) 37 | 38 | syncedStyle = statusStyle. 39 | Background(lipgloss.Color(syncedColor)) 40 | 41 | syncingStyle = statusStyle. 42 | Background(lipgloss.Color(syncingColor)) 43 | 44 | notSyncedStyle = statusStyle. 45 | Background(lipgloss.Color(notSyncedColor)) 46 | 47 | issueTypeColors = []string{ 48 | "#d3869b", 49 | "#b5e48c", 50 | "#90e0ef", 51 | "#ca7df9", 52 | "#ada7ff", 53 | "#bbd0ff", 54 | "#48cae4", 55 | "#8187dc", 56 | "#ffb4a2", 57 | "#b8bb26", 58 | "#ffc6ff", 59 | "#4895ef", 60 | "#83a598", 61 | "#fabd2f", 62 | } 63 | 64 | getIssueTypeStyle = func(issueType string) lipgloss.Style { 65 | baseStyle := lipgloss.NewStyle(). 66 | Foreground(lipgloss.Color(DefaultBackgroundColor)). 67 | Bold(true). 68 | Align(lipgloss.Center). 69 | Width(20) 70 | 71 | h := fnv.New32() 72 | _, err := h.Write([]byte(issueType)) 73 | if err != nil { 74 | return baseStyle.Background(lipgloss.Color(fallbackIssueColor)) 75 | } 76 | hash := h.Sum32() 77 | 78 | color := issueTypeColors[hash%uint32(len(issueTypeColors))] 79 | return baseStyle.Background(lipgloss.Color(color)) 80 | } 81 | 82 | assigneeColors = []string{ 83 | "#ccccff", // Lavender Blue 84 | "#ffa87d", // Light orange 85 | "#7385D8", // Light blue 86 | "#fabd2f", // Bright Yellow 87 | "#00abe5", // Deep Sky 88 | "#d3691e", // Chocolate 89 | } 90 | 91 | assigneeStyle = func(assignee string) lipgloss.Style { 92 | h := fnv.New32() 93 | _, err := h.Write([]byte(assignee)) 94 | if err != nil { 95 | lipgloss.NewStyle(). 96 | Foreground(lipgloss.Color(fallbackAssigneeColor)) 97 | } 98 | hash := h.Sum32() 99 | 100 | color := assigneeColors[int(hash)%len(assigneeColors)] 101 | 102 | return lipgloss.NewStyle(). 103 | Foreground(lipgloss.Color(color)) 104 | } 105 | 106 | issueStatusStyle = lipgloss.NewStyle(). 107 | Foreground(lipgloss.Color(issueStatusColor)) 108 | 109 | aggTimeSpentStyle = lipgloss.NewStyle(). 110 | PaddingLeft(2). 111 | Foreground(lipgloss.Color(aggTimeSpentColor)) 112 | ) 113 | -------------------------------------------------------------------------------- /internal/common/types.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "time" 7 | 8 | "github.com/dustin/go-humanize" 9 | ) 10 | 11 | var listWidth = 140 12 | 13 | const ( 14 | timeFormat = "2006/01/02 15:04" 15 | dayAndTimeFormat = "Mon, 15:04" 16 | dateFormat = "2006/01/02" 17 | timeOnlyFormat = "15:04" 18 | ) 19 | 20 | type Issue struct { 21 | IssueKey string 22 | IssueType string 23 | Summary string 24 | Assignee string 25 | Status string 26 | AggSecondsSpent int 27 | TrackingActive bool 28 | Desc string 29 | } 30 | 31 | func (issue *Issue) SetDesc() { 32 | // TODO: The padding here is a bit of a mess; make it more readable 33 | var assignee string 34 | var status string 35 | var totalSecsSpent string 36 | 37 | issueType := getIssueTypeStyle(issue.IssueType).Render(issue.IssueType) 38 | 39 | if issue.Assignee != "" { 40 | assignee = assigneeStyle(issue.Assignee).Render(RightPadTrim(issue.Assignee, listWidth/4)) 41 | } else { 42 | assignee = assigneeStyle(issue.Assignee).Render(RightPadTrim("", listWidth/4)) 43 | } 44 | 45 | status = issueStatusStyle.Render(RightPadTrim(issue.Status, listWidth/4)) 46 | 47 | if issue.AggSecondsSpent > 0 { 48 | totalSecsSpent = aggTimeSpentStyle.Render(HumanizeDuration(issue.AggSecondsSpent)) 49 | } 50 | 51 | issue.Desc = fmt.Sprintf("%s%s%s%s%s", RightPadTrim(issue.IssueKey, listWidth/4), status, assignee, issueType, totalSecsSpent) 52 | } 53 | 54 | func (issue Issue) Title() string { 55 | var trackingIndicator string 56 | if issue.TrackingActive { 57 | trackingIndicator = "⏲ " 58 | } 59 | return trackingIndicator + RightPadTrim(issue.Summary, int(float64(listWidth)*0.8)) 60 | } 61 | 62 | func (issue Issue) Description() string { 63 | return issue.Desc 64 | } 65 | 66 | func (issue Issue) FilterValue() string { return issue.IssueKey } 67 | 68 | type WorklogEntry struct { 69 | ID int 70 | IssueKey string 71 | BeginTS time.Time 72 | EndTS *time.Time 73 | Comment *string 74 | FallbackComment *string 75 | Active bool 76 | Synced bool 77 | SyncInProgress bool 78 | Error error 79 | } 80 | 81 | type SyncedWorklogEntry struct { 82 | ID int 83 | IssueKey string 84 | BeginTS time.Time 85 | EndTS time.Time 86 | Comment *string 87 | } 88 | 89 | func (entry *WorklogEntry) NeedsComment() bool { 90 | if entry.Comment == nil { 91 | return true 92 | } 93 | 94 | return strings.TrimSpace(*entry.Comment) == "" 95 | } 96 | 97 | func (entry *SyncedWorklogEntry) NeedsComment() bool { 98 | if entry.Comment == nil { 99 | return true 100 | } 101 | 102 | return strings.TrimSpace(*entry.Comment) == "" 103 | } 104 | 105 | func (entry WorklogEntry) SecsSpent() int { 106 | return int(entry.EndTS.Sub(entry.BeginTS).Seconds()) 107 | } 108 | 109 | func (entry WorklogEntry) Title() string { 110 | if entry.NeedsComment() { 111 | return "[NO COMMENT]" 112 | } 113 | 114 | return *entry.Comment 115 | } 116 | 117 | func (entry WorklogEntry) Description() string { 118 | if entry.Error != nil { 119 | return "error: " + entry.Error.Error() 120 | } 121 | 122 | var syncedStatus string 123 | var fallbackCommentStatus string 124 | var durationMsg string 125 | 126 | now := time.Now() 127 | 128 | startOfToday := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) 129 | 130 | if entry.EndTS != nil && startOfToday.Sub(*entry.EndTS) > 0 { 131 | if entry.BeginTS.Format(dateFormat) == entry.EndTS.Format(dateFormat) { 132 | durationMsg = fmt.Sprintf("%s ... %s", entry.BeginTS.Format(dayAndTimeFormat), entry.EndTS.Format(timeOnlyFormat)) 133 | } else { 134 | durationMsg = fmt.Sprintf("%s ... %s", entry.BeginTS.Format(dayAndTimeFormat), entry.EndTS.Format(dayAndTimeFormat)) 135 | } 136 | } else { 137 | durationMsg = fmt.Sprintf("%s ... %s", entry.BeginTS.Format(timeOnlyFormat), entry.EndTS.Format(timeOnlyFormat)) 138 | } 139 | 140 | timeSpentStr := HumanizeDuration(int(entry.EndTS.Sub(entry.BeginTS).Seconds())) 141 | 142 | if entry.Synced { 143 | syncedStatus = syncedStyle.Render("synced") 144 | } else if entry.SyncInProgress { 145 | syncedStatus = syncingStyle.Render("syncing") 146 | } else { 147 | syncedStatus = notSyncedStyle.Render("not synced") 148 | } 149 | 150 | if entry.NeedsComment() && entry.FallbackComment != nil { 151 | fallbackCommentStatus = usingFallbackCommentStyle.Render("fallback comment") 152 | } 153 | 154 | return fmt.Sprintf("%s%s%s%s%s", 155 | RightPadTrim(entry.IssueKey, listWidth/4), 156 | RightPadTrim(durationMsg, listWidth/4), 157 | RightPadTrim(fmt.Sprintf("(%s)", timeSpentStr), listWidth/6), 158 | syncedStatus, 159 | fallbackCommentStatus, 160 | ) 161 | } 162 | func (entry WorklogEntry) FilterValue() string { return entry.IssueKey } 163 | 164 | func (entry SyncedWorklogEntry) Title() string { 165 | if entry.NeedsComment() { 166 | return "[NO COMMENT]" 167 | } 168 | 169 | return *entry.Comment 170 | } 171 | 172 | func (entry SyncedWorklogEntry) Description() string { 173 | durationMsg := humanize.Time(entry.EndTS) 174 | timeSpentStr := HumanizeDuration(int(entry.EndTS.Sub(entry.BeginTS).Seconds())) 175 | return fmt.Sprintf("%s%s%s", 176 | RightPadTrim(entry.IssueKey, listWidth/4), 177 | RightPadTrim(durationMsg, listWidth/4), 178 | fmt.Sprintf("(%s)", timeSpentStr), 179 | ) 180 | } 181 | func (entry SyncedWorklogEntry) FilterValue() string { return entry.IssueKey } 182 | -------------------------------------------------------------------------------- /internal/common/utils.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "fmt" 5 | "math" 6 | "strings" 7 | "time" 8 | ) 9 | 10 | func RightPadTrim(s string, length int) string { 11 | if len(s) >= length { 12 | if length > 3 { 13 | return s[:length-3] + "..." 14 | } 15 | return s[:length] 16 | } 17 | return s + strings.Repeat(" ", length-len(s)) 18 | } 19 | 20 | func Trim(s string, length int) string { 21 | if len(s) >= length { 22 | if length > 3 { 23 | return s[:length-3] + "..." 24 | } 25 | return s[:length] 26 | } 27 | return s 28 | } 29 | 30 | func HumanizeDuration(durationInSecs int) string { 31 | duration := time.Duration(durationInSecs) * time.Second 32 | 33 | if duration.Seconds() < 60 { 34 | return fmt.Sprintf("%ds", int(duration.Seconds())) 35 | } 36 | 37 | if duration.Minutes() < 60 { 38 | return fmt.Sprintf("%dm", int(duration.Minutes())) 39 | } 40 | 41 | modMins := int(math.Mod(duration.Minutes(), 60)) 42 | 43 | if modMins == 0 { 44 | return fmt.Sprintf("%dh", int(duration.Hours())) 45 | } 46 | 47 | return fmt.Sprintf("%dh %dm", int(duration.Hours()), modMins) 48 | } 49 | -------------------------------------------------------------------------------- /internal/persistence/init.go: -------------------------------------------------------------------------------- 1 | package persistence 2 | 3 | import "database/sql" 4 | 5 | const ( 6 | DBVersion = "1" 7 | ) 8 | 9 | func InitDB(db *sql.DB) error { 10 | _, err := db.Exec(` 11 | CREATE TABLE IF NOT EXISTS issue_log ( 12 | ID INTEGER PRIMARY KEY AUTOINCREMENT, 13 | issue_key TEXT NOT NULL, 14 | begin_ts TIMESTAMP NOT NULL, 15 | end_ts TIMESTAMP, 16 | comment VARCHAR(255), 17 | active BOOLEAN NOT NULL, 18 | synced BOOLEAN NOT NULL 19 | ); 20 | 21 | CREATE TRIGGER IF NOT EXISTS prevent_duplicate_active_insert 22 | BEFORE INSERT ON issue_log 23 | BEGIN 24 | SELECT CASE 25 | WHEN EXISTS (SELECT 1 FROM issue_log WHERE active = 1) 26 | THEN RAISE(ABORT, 'Only one row with active=1 is allowed') 27 | END; 28 | END; 29 | `) 30 | if err != nil { 31 | return err 32 | } 33 | 34 | _, err = db.Exec(` 35 | DELETE from issue_log 36 | WHERE end_ts < DATE('now', '-60 days'); 37 | `) 38 | return err 39 | } 40 | -------------------------------------------------------------------------------- /internal/persistence/open.go: -------------------------------------------------------------------------------- 1 | package persistence 2 | 3 | import ( 4 | "database/sql" 5 | ) 6 | 7 | func GetDB(dbpath string) (*sql.DB, error) { 8 | db, err := sql.Open("sqlite", dbpath) 9 | if err != nil { 10 | return nil, err 11 | } 12 | 13 | db.SetMaxOpenConns(1) 14 | db.SetMaxIdleConns(1) 15 | return db, nil 16 | } 17 | -------------------------------------------------------------------------------- /internal/persistence/queries.go: -------------------------------------------------------------------------------- 1 | package persistence 2 | 3 | import ( 4 | "database/sql" 5 | "errors" 6 | "time" 7 | 8 | c "github.com/dhth/punchout/internal/common" 9 | ) 10 | 11 | var ( 12 | ErrNoTaskIsActive = errors.New("no task is active") 13 | ErrCouldntStopActiveTask = errors.New("couldn't stop active task") 14 | ErrCouldntStartTrackingTask = errors.New("couldn't start tracking task") 15 | ) 16 | 17 | func getNumActiveIssuesFromDB(db *sql.DB) (int, error) { 18 | row := db.QueryRow(` 19 | SELECT COUNT(*) 20 | from issue_log 21 | WHERE active=1 22 | `) 23 | var numActiveIssues int 24 | err := row.Scan(&numActiveIssues) 25 | return numActiveIssues, err 26 | } 27 | 28 | func getWorkLogsForIssueFromDB(db *sql.DB, issueKey string) ([]c.WorklogEntry, error) { 29 | var logEntries []c.WorklogEntry 30 | 31 | rows, err := db.Query(` 32 | SELECT ID, issue_key, begin_ts, end_ts, comment, active, synced 33 | FROM issue_log 34 | WHERE issue_key=? 35 | ORDER by end_ts DESC; 36 | `, issueKey) 37 | if err != nil { 38 | return nil, err 39 | } 40 | 41 | defer rows.Close() 42 | 43 | for rows.Next() { 44 | var entry c.WorklogEntry 45 | err = rows.Scan(&entry.ID, 46 | &entry.IssueKey, 47 | &entry.BeginTS, 48 | &entry.EndTS, 49 | &entry.Comment, 50 | &entry.Active, 51 | &entry.Synced, 52 | ) 53 | if err != nil { 54 | return nil, err 55 | } 56 | entry.BeginTS = entry.BeginTS.Local() 57 | if entry.EndTS != nil { 58 | *entry.EndTS = entry.EndTS.Local() 59 | } 60 | logEntries = append(logEntries, entry) 61 | } 62 | 63 | if iterErr := rows.Err(); iterErr != nil { 64 | return nil, iterErr 65 | } 66 | 67 | return logEntries, nil 68 | } 69 | 70 | func InsertNewWLInDB(db *sql.DB, issueKey string, beginTS time.Time) error { 71 | stmt, err := db.Prepare(` 72 | INSERT INTO issue_log (issue_key, begin_ts, active, synced) 73 | VALUES (?, ?, ?, ?); 74 | `) 75 | if err != nil { 76 | return err 77 | } 78 | defer stmt.Close() 79 | 80 | _, err = stmt.Exec(issueKey, beginTS.UTC(), true, 0) 81 | if err != nil { 82 | return err 83 | } 84 | 85 | return nil 86 | } 87 | 88 | func UpdateActiveWLInDB(db *sql.DB, issueKey, comment string, beginTS, endTS time.Time) error { 89 | stmt, err := db.Prepare(` 90 | UPDATE issue_log 91 | SET active = 0, 92 | begin_ts = ?, 93 | end_ts = ?, 94 | comment = ? 95 | WHERE issue_key = ? 96 | AND active = 1; 97 | `) 98 | if err != nil { 99 | return err 100 | } 101 | defer stmt.Close() 102 | 103 | _, err = stmt.Exec(beginTS.UTC(), endTS.UTC(), comment, issueKey) 104 | if err != nil { 105 | return err 106 | } 107 | 108 | return nil 109 | } 110 | 111 | func StopCurrentlyActiveWLInDB(db *sql.DB, issueKey string, endTS time.Time) error { 112 | stmt, err := db.Prepare(` 113 | UPDATE issue_log 114 | SET active = 0, 115 | end_ts = ? 116 | WHERE issue_key = ? 117 | AND active = 1; 118 | `) 119 | if err != nil { 120 | return err 121 | } 122 | defer stmt.Close() 123 | 124 | _, err = stmt.Exec(endTS.UTC(), issueKey) 125 | if err != nil { 126 | return err 127 | } 128 | 129 | return nil 130 | } 131 | 132 | func FetchWLsFromDB(db *sql.DB) ([]c.WorklogEntry, error) { 133 | var logEntries []c.WorklogEntry 134 | 135 | rows, err := db.Query(` 136 | SELECT ID, issue_key, begin_ts, end_ts, comment, active, synced 137 | FROM issue_log 138 | WHERE active=false AND synced=false 139 | ORDER by end_ts DESC; 140 | `) 141 | if err != nil { 142 | return nil, err 143 | } 144 | defer rows.Close() 145 | 146 | for rows.Next() { 147 | var entry c.WorklogEntry 148 | err = rows.Scan(&entry.ID, 149 | &entry.IssueKey, 150 | &entry.BeginTS, 151 | &entry.EndTS, 152 | &entry.Comment, 153 | &entry.Active, 154 | &entry.Synced, 155 | ) 156 | if err != nil { 157 | return nil, err 158 | } 159 | entry.BeginTS = entry.BeginTS.Local() 160 | if entry.EndTS != nil { 161 | *entry.EndTS = entry.EndTS.Local() 162 | } 163 | logEntries = append(logEntries, entry) 164 | } 165 | 166 | if iterErr := rows.Err(); iterErr != nil { 167 | return nil, iterErr 168 | } 169 | 170 | return logEntries, nil 171 | } 172 | 173 | func FetchSyncedWLsFromDB(db *sql.DB) ([]c.SyncedWorklogEntry, error) { 174 | var logEntries []c.SyncedWorklogEntry 175 | 176 | rows, err := db.Query(` 177 | SELECT ID, issue_key, begin_ts, end_ts, comment 178 | FROM issue_log 179 | WHERE active=false AND synced=true 180 | ORDER by end_ts DESC LIMIT 30; 181 | `) 182 | if err != nil { 183 | return nil, err 184 | } 185 | defer rows.Close() 186 | 187 | for rows.Next() { 188 | var entry c.SyncedWorklogEntry 189 | err = rows.Scan(&entry.ID, 190 | &entry.IssueKey, 191 | &entry.BeginTS, 192 | &entry.EndTS, 193 | &entry.Comment, 194 | ) 195 | if err != nil { 196 | return nil, err 197 | } 198 | entry.BeginTS = entry.BeginTS.Local() 199 | entry.EndTS = entry.EndTS.Local() 200 | logEntries = append(logEntries, entry) 201 | } 202 | 203 | if iterErr := rows.Err(); iterErr != nil { 204 | return nil, iterErr 205 | } 206 | 207 | return logEntries, nil 208 | } 209 | 210 | func DeleteWLInDB(db *sql.DB, id int) error { 211 | stmt, err := db.Prepare(` 212 | DELETE from issue_log 213 | WHERE ID=?; 214 | `) 215 | if err != nil { 216 | return err 217 | } 218 | defer stmt.Close() 219 | 220 | _, err = stmt.Exec(id) 221 | if err != nil { 222 | return err 223 | } 224 | 225 | return nil 226 | } 227 | 228 | func UpdateSyncStatusForWLInDB(db *sql.DB, id int) error { 229 | stmt, err := db.Prepare(` 230 | UPDATE issue_log 231 | SET synced = 1 232 | WHERE id = ?; 233 | `) 234 | if err != nil { 235 | return err 236 | } 237 | defer stmt.Close() 238 | 239 | _, err = stmt.Exec(id) 240 | if err != nil { 241 | return err 242 | } 243 | 244 | return nil 245 | } 246 | 247 | func UpdateSyncStatusAndCommentForWLInDB(db *sql.DB, id int, comment string) error { 248 | stmt, err := db.Prepare(` 249 | UPDATE issue_log 250 | SET synced = 1, 251 | comment = ? 252 | WHERE id = ?; 253 | `) 254 | if err != nil { 255 | return err 256 | } 257 | defer stmt.Close() 258 | 259 | _, err = stmt.Exec(comment, id) 260 | if err != nil { 261 | return err 262 | } 263 | 264 | return nil 265 | } 266 | 267 | func DeleteActiveLogInDB(db *sql.DB) error { 268 | stmt, err := db.Prepare(` 269 | DELETE FROM issue_log 270 | WHERE active=true; 271 | `) 272 | if err != nil { 273 | return err 274 | } 275 | defer stmt.Close() 276 | 277 | _, err = stmt.Exec() 278 | 279 | return err 280 | } 281 | 282 | func GetActiveIssueFromDB(db *sql.DB) (string, error) { 283 | row := db.QueryRow(` 284 | SELECT issue_key 285 | from issue_log 286 | WHERE active=1 287 | ORDER BY begin_ts DESC 288 | LIMIT 1 289 | `) 290 | var activeIssue string 291 | err := row.Scan(&activeIssue) 292 | if errors.Is(err, sql.ErrNoRows) { 293 | return "", ErrNoTaskIsActive 294 | } else if err != nil { 295 | return "", err 296 | } 297 | return activeIssue, nil 298 | } 299 | 300 | func QuickSwitchActiveWLInDB(db *sql.DB, currentIssue, selectedIssue string, currentTime time.Time) error { 301 | err := StopCurrentlyActiveWLInDB(db, currentIssue, currentTime) 302 | if err != nil { 303 | return ErrCouldntStopActiveTask 304 | } 305 | 306 | return InsertNewWLInDB(db, selectedIssue, currentTime) 307 | } 308 | 309 | func UpdateActiveWLBeginTSInDB(db *sql.DB, beginTS time.Time) error { 310 | stmt, err := db.Prepare(` 311 | UPDATE issue_log 312 | SET begin_ts=? 313 | WHERE active is true; 314 | `) 315 | if err != nil { 316 | return err 317 | } 318 | defer stmt.Close() 319 | 320 | _, err = stmt.Exec(beginTS.UTC(), true) 321 | if err != nil { 322 | return err 323 | } 324 | 325 | return nil 326 | } 327 | 328 | func UpdateActiveWLBeginTSAndCommentInDB(db *sql.DB, beginTS time.Time, comment string) error { 329 | stmt, err := db.Prepare(` 330 | UPDATE issue_log 331 | SET begin_ts=?, 332 | comment=? 333 | WHERE active is true; 334 | `) 335 | if err != nil { 336 | return err 337 | } 338 | defer stmt.Close() 339 | 340 | _, err = stmt.Exec(beginTS.UTC(), comment, true) 341 | if err != nil { 342 | return err 343 | } 344 | 345 | return nil 346 | } 347 | -------------------------------------------------------------------------------- /internal/persistence/queries_test.go: -------------------------------------------------------------------------------- 1 | package persistence 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | "testing" 7 | "time" 8 | 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/require" 11 | _ "modernc.org/sqlite" // sqlite driver 12 | ) 13 | 14 | func TestQueries(t *testing.T) { 15 | db, err := sql.Open("sqlite", ":memory:") 16 | require.NoErrorf(t, err, "error opening DB: %v", err) 17 | 18 | err = InitDB(db) 19 | require.NoErrorf(t, err, "error initializing DB: %v", err) 20 | 21 | t.Run("TestQuickSwitchActiveIssue", func(t *testing.T) { 22 | t.Cleanup(func() { cleanupDB(t, db) }) 23 | 24 | // GIVEN 25 | now := time.Now() 26 | activeIssueKey := "OLD-ACTIVE" 27 | newActiveIssueKey := "NEW-ACTIVE" 28 | beginTS := now.Add(time.Minute * -1 * time.Duration(30)) 29 | _, err := db.Exec(` 30 | INSERT INTO issue_log (issue_key, begin_ts, active, synced) 31 | VALUES (?, ?, ?, ?); 32 | `, activeIssueKey, beginTS, true, 0) 33 | require.NoError(t, err, "couldn't insert active worklog") 34 | 35 | // WHEN 36 | err = QuickSwitchActiveWLInDB(db, activeIssueKey, newActiveIssueKey, now) 37 | require.NoError(t, err, "quick switching returned an error") 38 | 39 | // THEN 40 | numActiveIssues, err := getNumActiveIssuesFromDB(db) 41 | require.NoError(t, err, "couldn't get number of active issues") 42 | gotNewActive, err := GetActiveIssueFromDB(db) 43 | require.NoError(t, err, "couldn't get active issue") 44 | wl1, err := getWorkLogsForIssueFromDB(db, activeIssueKey) 45 | require.NoError(t, err, "couldn't get worklog entries for active issue") 46 | wl2, err := getWorkLogsForIssueFromDB(db, newActiveIssueKey) 47 | require.NoError(t, err, "couldn't get worklog entries for new issue") 48 | 49 | assert.Equal(t, 1, numActiveIssues, "number of active issues is incorrect") 50 | assert.Equal(t, newActiveIssueKey, gotNewActive, "new active issue key is incorrect") 51 | assert.Len(t, wl1, 1, "work log entries for older issue is incorrect") 52 | assert.Len(t, wl2, 1, "work log entries for new issue is incorrect") 53 | }) 54 | } 55 | 56 | func cleanupDB(t *testing.T, testDB *sql.DB) { 57 | t.Helper() 58 | 59 | var err error 60 | for _, tbl := range []string{"issue_log"} { 61 | _, err = testDB.Exec(fmt.Sprintf("DELETE FROM %s", tbl)) 62 | require.NoErrorf(t, err, "failed to clean up table %q: %v", tbl, err) 63 | 64 | _, err := testDB.Exec("DELETE FROM sqlite_sequence WHERE name=?;", tbl) 65 | require.NoErrorf(t, err, "failed to reset auto increment for table %q: %v", tbl, err) 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /internal/ui/cmds.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "database/sql" 5 | "errors" 6 | "os/exec" 7 | "runtime" 8 | "time" 9 | 10 | jira "github.com/andygrunwald/go-jira/v2/onpremise" 11 | tea "github.com/charmbracelet/bubbletea" 12 | common "github.com/dhth/punchout/internal/common" 13 | pers "github.com/dhth/punchout/internal/persistence" 14 | 15 | _ "modernc.org/sqlite" // sqlite driver 16 | ) 17 | 18 | var errWorklogsEndTSIsEmpty = errors.New("worklog's end timestamp is empty") 19 | 20 | func toggleTracking(db *sql.DB, selectedIssue string, beginTS, endTS time.Time, comment string) tea.Cmd { 21 | return func() tea.Msg { 22 | row := db.QueryRow(` 23 | SELECT issue_key 24 | from issue_log 25 | WHERE active=1 26 | ORDER BY begin_ts DESC 27 | LIMIT 1 28 | `) 29 | var trackStatus trackingStatus 30 | var activeIssue string 31 | err := row.Scan(&activeIssue) 32 | if errors.Is(err, sql.ErrNoRows) { 33 | trackStatus = trackingInactive 34 | } else if err != nil { 35 | return trackingToggledInDB{err: err} 36 | } else { 37 | trackStatus = trackingActive 38 | } 39 | 40 | switch trackStatus { 41 | case trackingInactive: 42 | err = pers.InsertNewWLInDB(db, selectedIssue, beginTS) 43 | if err != nil { 44 | return trackingToggledInDB{err: err} 45 | } 46 | return trackingToggledInDB{activeIssue: selectedIssue} 47 | 48 | default: 49 | err := pers.UpdateActiveWLInDB(db, activeIssue, comment, beginTS, endTS) 50 | if err != nil { 51 | return trackingToggledInDB{err: err} 52 | } 53 | return trackingToggledInDB{activeIssue: "", finished: true} 54 | } 55 | } 56 | } 57 | 58 | func quickSwitchActiveIssue(db *sql.DB, selectedIssue string, currentTime time.Time) tea.Cmd { 59 | return func() tea.Msg { 60 | activeIssue, err := pers.GetActiveIssueFromDB(db) 61 | if err != nil { 62 | return activeWLSwitchedInDB{"", selectedIssue, currentTime, err} 63 | } 64 | 65 | err = pers.QuickSwitchActiveWLInDB(db, activeIssue, selectedIssue, currentTime) 66 | if err != nil { 67 | return activeWLSwitchedInDB{activeIssue, selectedIssue, currentTime, err} 68 | } 69 | 70 | return activeWLSwitchedInDB{activeIssue, selectedIssue, currentTime, nil} 71 | } 72 | } 73 | 74 | func updateActiveWL(db *sql.DB, beginTS time.Time, comment *string) tea.Cmd { 75 | return func() tea.Msg { 76 | var err error 77 | if comment == nil { 78 | err = pers.UpdateActiveWLBeginTSInDB(db, beginTS) 79 | } else { 80 | err = pers.UpdateActiveWLBeginTSAndCommentInDB(db, beginTS, *comment) 81 | } 82 | 83 | return activeWLUpdatedInDB{beginTS, comment, err} 84 | } 85 | } 86 | 87 | func insertManualEntry(db *sql.DB, issueKey string, beginTS time.Time, endTS time.Time, comment string) tea.Cmd { 88 | return func() tea.Msg { 89 | stmt, err := db.Prepare(` 90 | INSERT INTO issue_log (issue_key, begin_ts, end_ts, comment, active, synced) 91 | VALUES (?, ?, ?, ?, ?, ?); 92 | `) 93 | if err != nil { 94 | return manualWLInsertedInDB{issueKey, err} 95 | } 96 | defer stmt.Close() 97 | 98 | _, err = stmt.Exec(issueKey, beginTS, endTS, comment, false, false) 99 | if err != nil { 100 | return manualWLInsertedInDB{issueKey, err} 101 | } 102 | 103 | return manualWLInsertedInDB{issueKey, nil} 104 | } 105 | } 106 | 107 | func deleteActiveIssueLog(db *sql.DB) tea.Cmd { 108 | return func() tea.Msg { 109 | err := pers.DeleteActiveLogInDB(db) 110 | return activeWLDeletedFromDB{err} 111 | } 112 | } 113 | 114 | func updateManualEntry(db *sql.DB, rowID int, issueKey string, beginTS time.Time, endTS time.Time, comment string) tea.Cmd { 115 | return func() tea.Msg { 116 | stmt, err := db.Prepare(` 117 | UPDATE issue_log 118 | SET begin_ts = ?, 119 | end_ts = ?, 120 | comment = ? 121 | WHERE ID = ?; 122 | `) 123 | if err != nil { 124 | return wLUpdatedInDB{rowID, issueKey, err} 125 | } 126 | defer stmt.Close() 127 | 128 | _, err = stmt.Exec(beginTS.UTC(), endTS.UTC(), comment, rowID) 129 | if err != nil { 130 | return wLUpdatedInDB{rowID, issueKey, err} 131 | } 132 | 133 | return wLUpdatedInDB{rowID, issueKey, nil} 134 | } 135 | } 136 | 137 | func fetchActiveStatus(db *sql.DB, interval time.Duration) tea.Cmd { 138 | return tea.Tick(interval, func(time.Time) tea.Msg { 139 | row := db.QueryRow(` 140 | SELECT issue_key, begin_ts, comment 141 | from issue_log 142 | WHERE active=1 143 | ORDER BY begin_ts DESC 144 | LIMIT 1 145 | `) 146 | var activeIssue string 147 | var beginTS time.Time 148 | var comment *string 149 | err := row.Scan(&activeIssue, &beginTS, &comment) 150 | if err == sql.ErrNoRows { 151 | return activeWLFetchedFromDB{activeIssue: activeIssue} 152 | } 153 | if err != nil { 154 | return activeWLFetchedFromDB{err: err} 155 | } 156 | 157 | return activeWLFetchedFromDB{activeIssue: activeIssue, beginTS: beginTS, comment: comment} 158 | }) 159 | } 160 | 161 | func fetchWorkLogs(db *sql.DB) tea.Cmd { 162 | return func() tea.Msg { 163 | entries, err := pers.FetchWLsFromDB(db) 164 | return wLEntriesFetchedFromDB{ 165 | entries: entries, 166 | err: err, 167 | } 168 | } 169 | } 170 | 171 | func fetchSyncedWorkLogs(db *sql.DB) tea.Cmd { 172 | return func() tea.Msg { 173 | entries, err := pers.FetchSyncedWLsFromDB(db) 174 | return syncedWLEntriesFetchedFromDB{ 175 | entries: entries, 176 | err: err, 177 | } 178 | } 179 | } 180 | 181 | func deleteLogEntry(db *sql.DB, id int) tea.Cmd { 182 | return func() tea.Msg { 183 | err := pers.DeleteWLInDB(db, id) 184 | return wLDeletedFromDB{ 185 | err: err, 186 | } 187 | } 188 | } 189 | 190 | func updateSyncStatusForEntry(db *sql.DB, entry common.WorklogEntry, index int, fallbackCommentUsed bool) tea.Cmd { 191 | return func() tea.Msg { 192 | var err error 193 | var comment string 194 | if entry.Comment != nil { 195 | comment = *entry.Comment 196 | } 197 | if fallbackCommentUsed { 198 | err = pers.UpdateSyncStatusAndCommentForWLInDB(db, entry.ID, comment) 199 | } else { 200 | err = pers.UpdateSyncStatusForWLInDB(db, entry.ID) 201 | } 202 | 203 | return wLSyncUpdatedInDB{ 204 | entry: entry, 205 | index: index, 206 | err: err, 207 | } 208 | } 209 | } 210 | 211 | func fetchJIRAIssues(cl *jira.Client, jql string) tea.Cmd { 212 | return func() tea.Msg { 213 | jIssues, statusCode, err := getIssues(cl, jql) 214 | var issues []common.Issue 215 | if err != nil { 216 | return issuesFetchedFromJIRA{issues, statusCode, err} 217 | } 218 | 219 | for _, issue := range jIssues { 220 | var assignee string 221 | var totalSecsSpent int 222 | var status string 223 | if issue.Fields != nil { 224 | if issue.Fields.Assignee != nil { 225 | assignee = issue.Fields.Assignee.DisplayName 226 | } 227 | 228 | totalSecsSpent = issue.Fields.AggregateTimeSpent 229 | 230 | if issue.Fields.Status != nil { 231 | status = issue.Fields.Status.Name 232 | } 233 | } 234 | issues = append(issues, common.Issue{ 235 | IssueKey: issue.Key, 236 | IssueType: issue.Fields.Type.Name, 237 | Summary: issue.Fields.Summary, 238 | Assignee: assignee, 239 | Status: status, 240 | AggSecondsSpent: totalSecsSpent, 241 | TrackingActive: false, 242 | }) 243 | } 244 | return issuesFetchedFromJIRA{issues, statusCode, nil} 245 | } 246 | } 247 | 248 | func syncWorklogWithJIRA(cl *jira.Client, entry common.WorklogEntry, fallbackComment *string, index int, timeDeltaMins int) tea.Cmd { 249 | return func() tea.Msg { 250 | var fallbackCmtUsed bool 251 | if entry.EndTS == nil { 252 | return wLSyncedToJIRA{index, entry, fallbackCmtUsed, errWorklogsEndTSIsEmpty} 253 | } 254 | 255 | var comment string 256 | if entry.NeedsComment() && fallbackComment != nil { 257 | comment = *fallbackComment 258 | fallbackCmtUsed = true 259 | } else if entry.Comment != nil { 260 | comment = *entry.Comment 261 | } 262 | 263 | err := syncWLToJIRA(cl, entry.IssueKey, entry.BeginTS, *entry.EndTS, comment, timeDeltaMins) 264 | return wLSyncedToJIRA{index, entry, fallbackCmtUsed, err} 265 | } 266 | } 267 | 268 | func hideHelp(interval time.Duration) tea.Cmd { 269 | return tea.Tick(interval, func(time.Time) tea.Msg { 270 | return hideHelpMsg{} 271 | }) 272 | } 273 | 274 | func openURLInBrowser(url string) tea.Cmd { 275 | var openCmd string 276 | switch runtime.GOOS { 277 | case "darwin": 278 | openCmd = "open" 279 | default: 280 | openCmd = "xdg-open" 281 | } 282 | c := exec.Command(openCmd, url) 283 | return tea.ExecProcess(c, func(err error) tea.Msg { 284 | if err != nil { 285 | return urlOpenedinBrowserMsg{url: url, err: err} 286 | } 287 | return tea.Msg(urlOpenedinBrowserMsg{url: url}) 288 | }) 289 | } 290 | -------------------------------------------------------------------------------- /internal/ui/date_helpers.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import "time" 4 | 5 | type timeShiftDirection uint8 6 | 7 | const ( 8 | shiftForward timeShiftDirection = iota 9 | shiftBackward 10 | ) 11 | 12 | type timeShiftDuration uint8 13 | 14 | const ( 15 | shiftMinute timeShiftDuration = iota 16 | shiftFiveMinutes 17 | shiftHour 18 | shiftDay 19 | ) 20 | 21 | func getShiftedTime(ts time.Time, direction timeShiftDirection, duration timeShiftDuration) time.Time { 22 | var d time.Duration 23 | 24 | switch duration { 25 | case shiftMinute: 26 | d = time.Minute 27 | case shiftFiveMinutes: 28 | d = time.Minute * 5 29 | case shiftHour: 30 | d = time.Hour 31 | case shiftDay: 32 | d = time.Hour * 24 33 | } 34 | 35 | if direction == shiftBackward { 36 | d = -1 * d 37 | } 38 | return ts.Add(d) 39 | } 40 | -------------------------------------------------------------------------------- /internal/ui/handle.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "strings" 7 | "time" 8 | 9 | "github.com/charmbracelet/bubbles/list" 10 | "github.com/charmbracelet/bubbles/viewport" 11 | tea "github.com/charmbracelet/bubbletea" 12 | "github.com/charmbracelet/lipgloss" 13 | c "github.com/dhth/punchout/internal/common" 14 | pers "github.com/dhth/punchout/internal/persistence" 15 | ) 16 | 17 | func (m *Model) getCmdToUpdateActiveWL() tea.Cmd { 18 | beginTS, err := time.ParseInLocation(timeFormat, m.trackingInputs[entryBeginTS].Value(), time.Local) 19 | if err != nil { 20 | m.message = err.Error() 21 | return nil 22 | } 23 | commentValue := m.trackingInputs[entryComment].Value() 24 | 25 | var comment *string 26 | if strings.TrimSpace(commentValue) != "" { 27 | comment = &commentValue 28 | } 29 | m.trackingInputs[entryBeginTS].SetValue("") 30 | m.activeView = issueListView 31 | return updateActiveWL(m.db, beginTS, comment) 32 | } 33 | 34 | func (m *Model) getCmdToSaveActiveWL() tea.Cmd { 35 | beginTS, err := time.ParseInLocation(timeFormat, m.trackingInputs[entryBeginTS].Value(), time.Local) 36 | if err != nil { 37 | m.message = err.Error() 38 | return nil 39 | } 40 | m.activeIssueBeginTS = beginTS.Local() 41 | 42 | endTS, err := time.ParseInLocation(timeFormat, m.trackingInputs[entryEndTS].Value(), time.Local) 43 | if err != nil { 44 | m.message = err.Error() 45 | return nil 46 | } 47 | m.activeIssueEndTS = endTS.Local() 48 | 49 | if m.activeIssueEndTS.Sub(m.activeIssueBeginTS).Seconds() <= 0 { 50 | m.message = "time spent needs to be greater than zero" 51 | return nil 52 | } 53 | 54 | comment := m.trackingInputs[entryComment].Value() 55 | 56 | m.activeView = issueListView 57 | for i := range m.trackingInputs { 58 | m.trackingInputs[i].SetValue("") 59 | } 60 | 61 | return toggleTracking(m.db, 62 | m.activeIssue, 63 | m.activeIssueBeginTS, 64 | m.activeIssueEndTS, 65 | comment, 66 | ) 67 | } 68 | 69 | func (m *Model) getCmdToSaveOrUpdateWL() tea.Cmd { 70 | beginTS, err := time.ParseInLocation(timeFormat, m.trackingInputs[entryBeginTS].Value(), time.Local) 71 | if err != nil { 72 | m.message = err.Error() 73 | return nil 74 | } 75 | 76 | endTS, err := time.ParseInLocation(timeFormat, m.trackingInputs[entryEndTS].Value(), time.Local) 77 | if err != nil { 78 | m.message = err.Error() 79 | return nil 80 | } 81 | 82 | if endTS.Sub(beginTS).Seconds() <= 0 { 83 | m.message = "time spent needs to be greater than zero" 84 | return nil 85 | } 86 | 87 | issue, ok := m.issueList.SelectedItem().(*c.Issue) 88 | 89 | var cmd tea.Cmd 90 | if ok { 91 | switch m.worklogSaveType { 92 | case worklogInsert: 93 | cmd = insertManualEntry(m.db, 94 | issue.IssueKey, 95 | beginTS, 96 | endTS, 97 | m.trackingInputs[entryComment].Value(), 98 | ) 99 | m.activeView = issueListView 100 | case worklogUpdate: 101 | wl, ok := m.worklogList.SelectedItem().(c.WorklogEntry) 102 | if ok { 103 | cmd = updateManualEntry(m.db, 104 | wl.ID, 105 | wl.IssueKey, 106 | beginTS, 107 | endTS, 108 | m.trackingInputs[entryComment].Value(), 109 | ) 110 | m.activeView = wLView 111 | } 112 | } 113 | } 114 | for i := range m.trackingInputs { 115 | m.trackingInputs[i].SetValue("") 116 | } 117 | return cmd 118 | } 119 | 120 | func (m *Model) handleEscape() bool { 121 | var quit bool 122 | 123 | switch m.activeView { 124 | case issueListView: 125 | quit = true 126 | case wLView: 127 | quit = true 128 | case syncedWLView: 129 | quit = true 130 | case helpView: 131 | quit = true 132 | case editActiveWLView: 133 | m.activeView = issueListView 134 | case saveActiveWLView: 135 | m.activeView = issueListView 136 | m.trackingInputs[entryComment].SetValue("") 137 | case wlEntryView: 138 | switch m.worklogSaveType { 139 | case worklogInsert: 140 | m.activeView = issueListView 141 | case worklogUpdate: 142 | m.activeView = wLView 143 | } 144 | for i := range m.trackingInputs { 145 | m.trackingInputs[i].SetValue("") 146 | } 147 | } 148 | 149 | return quit 150 | } 151 | 152 | func (m *Model) getCmdToGoForwardsInViews() tea.Cmd { 153 | var cmd tea.Cmd 154 | switch m.activeView { 155 | case issueListView: 156 | m.activeView = wLView 157 | cmd = fetchWorkLogs(m.db) 158 | case wLView: 159 | m.activeView = syncedWLView 160 | cmd = fetchSyncedWorkLogs(m.db) 161 | case syncedWLView: 162 | m.activeView = issueListView 163 | case editActiveWLView: 164 | switch m.trackingFocussedField { 165 | case entryBeginTS: 166 | m.trackingFocussedField = entryComment 167 | case entryComment: 168 | m.trackingFocussedField = entryBeginTS 169 | } 170 | for i := range m.trackingInputs { 171 | m.trackingInputs[i].Blur() 172 | } 173 | m.trackingInputs[m.trackingFocussedField].Focus() 174 | case saveActiveWLView, wlEntryView: 175 | switch m.trackingFocussedField { 176 | case entryBeginTS: 177 | m.trackingFocussedField = entryEndTS 178 | case entryEndTS: 179 | m.trackingFocussedField = entryComment 180 | case entryComment: 181 | m.trackingFocussedField = entryBeginTS 182 | } 183 | for i := range m.trackingInputs { 184 | m.trackingInputs[i].Blur() 185 | } 186 | m.trackingInputs[m.trackingFocussedField].Focus() 187 | } 188 | 189 | return cmd 190 | } 191 | 192 | func (m *Model) getCmdToGoBackwardsInViews() tea.Cmd { 193 | var cmd tea.Cmd 194 | switch m.activeView { 195 | case wLView: 196 | m.activeView = issueListView 197 | case syncedWLView: 198 | m.activeView = wLView 199 | cmd = fetchWorkLogs(m.db) 200 | case issueListView: 201 | m.activeView = syncedWLView 202 | cmd = fetchSyncedWorkLogs(m.db) 203 | case editActiveWLView: 204 | switch m.trackingFocussedField { 205 | case entryBeginTS: 206 | m.trackingFocussedField = entryComment 207 | case entryComment: 208 | m.trackingFocussedField = entryBeginTS 209 | } 210 | for i := range m.trackingInputs { 211 | m.trackingInputs[i].Blur() 212 | } 213 | m.trackingInputs[m.trackingFocussedField].Focus() 214 | case saveActiveWLView, wlEntryView: 215 | switch m.trackingFocussedField { 216 | case entryBeginTS: 217 | m.trackingFocussedField = entryComment 218 | case entryEndTS: 219 | m.trackingFocussedField = entryBeginTS 220 | case entryComment: 221 | m.trackingFocussedField = entryEndTS 222 | } 223 | for i := range m.trackingInputs { 224 | m.trackingInputs[i].Blur() 225 | } 226 | m.trackingInputs[m.trackingFocussedField].Focus() 227 | } 228 | 229 | return cmd 230 | } 231 | 232 | func (m *Model) handleRequestToGoBackOrQuit() bool { 233 | var quit bool 234 | switch m.activeView { 235 | case issueListView: 236 | fs := m.issueList.FilterState() 237 | if fs == list.Filtering || fs == list.FilterApplied { 238 | m.issueList.ResetFilter() 239 | } else { 240 | quit = true 241 | } 242 | case wLView: 243 | fs := m.worklogList.FilterState() 244 | if fs == list.Filtering || fs == list.FilterApplied { 245 | m.worklogList.ResetFilter() 246 | } else { 247 | m.activeView = issueListView 248 | } 249 | case syncedWLView: 250 | m.activeView = wLView 251 | case helpView: 252 | m.activeView = m.lastView 253 | default: 254 | quit = true 255 | } 256 | 257 | return quit 258 | } 259 | 260 | func (m *Model) getCmdToReloadData() tea.Cmd { 261 | var cmd tea.Cmd 262 | switch m.activeView { 263 | case issueListView: 264 | m.issueList.Title = "fetching..." 265 | m.issueList.Styles.Title = m.issueList.Styles.Title.Background(lipgloss.Color(issueListUnfetchedColor)) 266 | cmd = fetchJIRAIssues(m.jiraClient, m.jql) 267 | case wLView: 268 | cmd = fetchWorkLogs(m.db) 269 | m.worklogList.ResetSelected() 270 | case syncedWLView: 271 | cmd = fetchSyncedWorkLogs(m.db) 272 | m.syncedWorklogList.ResetSelected() 273 | } 274 | 275 | return cmd 276 | } 277 | 278 | func (m *Model) handleRequestToGoToActiveIssue() { 279 | if m.activeView == issueListView { 280 | if m.trackingActive { 281 | if m.issueList.IsFiltered() { 282 | m.issueList.ResetFilter() 283 | } 284 | activeIndex, ok := m.issueIndexMap[m.activeIssue] 285 | if ok { 286 | m.issueList.Select(activeIndex) 287 | } 288 | } else { 289 | m.message = "Nothing is being tracked right now" 290 | } 291 | } 292 | } 293 | 294 | func (m *Model) handleRequestToUpdateActiveWL() { 295 | m.activeView = editActiveWLView 296 | m.trackingFocussedField = entryBeginTS 297 | beginTSStr := m.activeIssueBeginTS.Format(timeFormat) 298 | m.trackingInputs[entryBeginTS].SetValue(beginTSStr) 299 | if m.activeIssueComment != nil { 300 | m.trackingInputs[entryComment].SetValue(*m.activeIssueComment) 301 | } else { 302 | m.trackingInputs[entryComment].SetValue("") 303 | } 304 | 305 | for i := range m.trackingInputs { 306 | m.trackingInputs[i].Blur() 307 | } 308 | m.trackingInputs[m.trackingFocussedField].Focus() 309 | } 310 | 311 | func (m *Model) handleRequestToCreateManualWL() { 312 | m.activeView = wlEntryView 313 | m.worklogSaveType = worklogInsert 314 | m.trackingFocussedField = entryBeginTS 315 | currentTime := time.Now() 316 | currentTimeStr := currentTime.Format(timeFormat) 317 | 318 | m.trackingInputs[entryBeginTS].SetValue(currentTimeStr) 319 | m.trackingInputs[entryEndTS].SetValue(currentTimeStr) 320 | 321 | for i := range m.trackingInputs { 322 | m.trackingInputs[i].Blur() 323 | } 324 | m.trackingInputs[m.trackingFocussedField].Focus() 325 | } 326 | 327 | func (m *Model) handleRequestToUpdateSavedWL() { 328 | wl, ok := m.worklogList.SelectedItem().(c.WorklogEntry) 329 | if !ok { 330 | return 331 | } 332 | 333 | m.activeView = wlEntryView 334 | m.worklogSaveType = worklogUpdate 335 | if wl.NeedsComment() { 336 | m.trackingFocussedField = entryComment 337 | } else { 338 | m.trackingFocussedField = entryBeginTS 339 | } 340 | 341 | beginTSStr := wl.BeginTS.Format(timeFormat) 342 | endTSStr := wl.EndTS.Format(timeFormat) 343 | 344 | m.trackingInputs[entryBeginTS].SetValue(beginTSStr) 345 | m.trackingInputs[entryEndTS].SetValue(endTSStr) 346 | var comment string 347 | if wl.Comment != nil { 348 | comment = *wl.Comment 349 | } 350 | m.trackingInputs[entryComment].SetValue(comment) 351 | 352 | for i := range m.trackingInputs { 353 | m.trackingInputs[i].Blur() 354 | } 355 | m.trackingInputs[m.trackingFocussedField].Focus() 356 | } 357 | 358 | func (m *Model) handleRequestToSyncTimestamps() { 359 | switch m.trackingFocussedField { 360 | case entryBeginTS: 361 | tsStrToSync := m.trackingInputs[entryEndTS].Value() 362 | _, err := time.ParseInLocation(timeFormat, tsStrToSync, time.Local) 363 | if err != nil { 364 | m.message = fmt.Sprintf("end timestamp is invalid: %s", err.Error()) 365 | return 366 | } 367 | m.trackingInputs[entryBeginTS].SetValue(tsStrToSync) 368 | case entryEndTS: 369 | tsStrToSync := m.trackingInputs[entryBeginTS].Value() 370 | _, err := time.ParseInLocation(timeFormat, tsStrToSync, time.Local) 371 | if err != nil { 372 | m.message = fmt.Sprintf("begin timestamp is invalid: %s", err.Error()) 373 | return 374 | } 375 | m.trackingInputs[entryEndTS].SetValue(tsStrToSync) 376 | default: 377 | m.message = "you need to have the cursor on either one of the two timestamps to sync them" 378 | } 379 | } 380 | 381 | func (m *Model) getCmdToDeleteWL() tea.Cmd { 382 | issue, ok := m.worklogList.SelectedItem().(c.WorklogEntry) 383 | if !ok { 384 | msg := "Couldn't delete worklog entry" 385 | m.message = msg 386 | m.messages = append(m.messages, msg) 387 | return nil 388 | } 389 | 390 | return deleteLogEntry(m.db, issue.ID) 391 | } 392 | 393 | func (m *Model) getCmdToQuickSwitchTracking() tea.Cmd { 394 | issue, ok := m.issueList.SelectedItem().(*c.Issue) 395 | if !ok { 396 | m.message = "Something went wrong" 397 | return nil 398 | } 399 | 400 | if issue.IssueKey == m.activeIssue { 401 | return nil 402 | } 403 | 404 | if !m.trackingActive { 405 | m.changesLocked = true 406 | m.activeIssueBeginTS = time.Now() 407 | return toggleTracking(m.db, 408 | issue.IssueKey, 409 | m.activeIssueBeginTS, 410 | m.activeIssueEndTS, 411 | "", 412 | ) 413 | } 414 | 415 | return quickSwitchActiveIssue(m.db, issue.IssueKey, time.Now()) 416 | } 417 | 418 | func (m *Model) getCmdToToggleTracking() tea.Cmd { 419 | if m.issueList.FilterState() == list.Filtering { 420 | return nil 421 | } 422 | 423 | if m.changesLocked { 424 | message := "Changes locked momentarily" 425 | m.message = message 426 | m.messages = append(m.messages, message) 427 | return nil 428 | } 429 | 430 | if m.lastChange == updateChange { 431 | return m.getCmdToStartTracking() 432 | } 433 | 434 | m.handleStoppingOfTracking() 435 | return nil 436 | } 437 | 438 | func (m *Model) getCmdToStartTracking() tea.Cmd { 439 | issue, ok := m.issueList.SelectedItem().(*c.Issue) 440 | if !ok { 441 | message := "Something went horribly wrong" 442 | m.message = message 443 | m.messages = append(m.messages, message) 444 | return nil 445 | } 446 | 447 | m.changesLocked = true 448 | m.activeIssueBeginTS = time.Now().Truncate(time.Second) 449 | return toggleTracking(m.db, 450 | issue.IssueKey, 451 | m.activeIssueBeginTS, 452 | m.activeIssueEndTS, 453 | "", 454 | ) 455 | } 456 | 457 | func (m *Model) handleStoppingOfTracking() { 458 | currentTime := time.Now() 459 | beginTimeStr := m.activeIssueBeginTS.Format(timeFormat) 460 | currentTimeStr := currentTime.Format(timeFormat) 461 | 462 | m.trackingInputs[entryBeginTS].SetValue(beginTimeStr) 463 | m.trackingInputs[entryEndTS].SetValue(currentTimeStr) 464 | if m.activeIssueComment != nil { 465 | m.trackingInputs[entryComment].SetValue(*m.activeIssueComment) 466 | } else { 467 | m.trackingInputs[entryComment].SetValue("") 468 | } 469 | 470 | for i := range m.trackingInputs { 471 | m.trackingInputs[i].Blur() 472 | } 473 | 474 | m.activeView = saveActiveWLView 475 | m.trackingFocussedField = entryComment 476 | m.trackingInputs[m.trackingFocussedField].Focus() 477 | } 478 | 479 | func (m *Model) getCmdToSyncWLToJIRA() []tea.Cmd { 480 | var cmds []tea.Cmd 481 | toSyncNum := 0 482 | for i, entry := range m.worklogList.Items() { 483 | if wl, ok := entry.(c.WorklogEntry); ok { 484 | if wl.Synced { 485 | continue 486 | } 487 | 488 | wl.SyncInProgress = true 489 | m.worklogList.SetItem(i, wl) 490 | cmds = append(cmds, syncWorklogWithJIRA(m.jiraClient, wl, m.fallbackComment, i, m.jiraTimeDeltaMins)) 491 | toSyncNum++ 492 | } 493 | } 494 | if toSyncNum == 0 { 495 | m.message = "nothing to sync" 496 | } 497 | 498 | return cmds 499 | } 500 | 501 | func (m *Model) getCmdToOpenIssueInBrowser() tea.Cmd { 502 | selectedIssue := m.issueList.SelectedItem().FilterValue() 503 | return openURLInBrowser(fmt.Sprintf("%sbrowse/%s", 504 | m.jiraClient.BaseURL.String(), 505 | selectedIssue)) 506 | } 507 | 508 | func (m *Model) handleWindowResizing(msg tea.WindowSizeMsg) { 509 | w, h := listStyle.GetFrameSize() 510 | m.terminalHeight = msg.Height 511 | 512 | m.issueList.SetWidth(msg.Width - w) 513 | m.worklogList.SetWidth(msg.Width - w) 514 | m.syncedWorklogList.SetWidth(msg.Width - w) 515 | m.issueList.SetHeight(msg.Height - h - 2) 516 | m.worklogList.SetHeight(msg.Height - h - 2) 517 | m.syncedWorklogList.SetHeight(msg.Height - h - 2) 518 | 519 | if !m.helpVPReady { 520 | m.helpVP = viewport.New(w-5, m.terminalHeight-7) 521 | m.helpVP.HighPerformanceRendering = false 522 | m.helpVP.SetContent(helpText) 523 | m.helpVPReady = true 524 | } else { 525 | m.helpVP.Height = m.terminalHeight - 7 526 | m.helpVP.Width = w - 5 527 | } 528 | } 529 | 530 | func (m *Model) handleIssuesFetchedFromJIRAMsg(msg issuesFetchedFromJIRA) tea.Cmd { 531 | if msg.err != nil { 532 | var remoteServerName string 533 | if msg.responseStatusCode >= 400 && msg.responseStatusCode < 500 { 534 | switch m.installationType { 535 | case OnPremiseInstallation: 536 | remoteServerName = "Your on-premise JIRA installation" 537 | case CloudInstallation: 538 | remoteServerName = "Atlassian Cloud" 539 | } 540 | m.message = fmt.Sprintf("%s returned a %d status code, check if your configuration is correct", 541 | remoteServerName, 542 | msg.responseStatusCode) 543 | } else { 544 | m.message = fmt.Sprintf("error fetching issues from JIRA: %s", msg.err.Error()) 545 | } 546 | m.messages = append(m.messages, m.message) 547 | m.issueList.Title = "Failure" 548 | m.issueList.Styles.Title = m.issueList.Styles.Title.Background(lipgloss.Color(failureColor)) 549 | return nil 550 | } 551 | 552 | issues := make([]list.Item, 0, len(msg.issues)) 553 | for i, issue := range msg.issues { 554 | issue.SetDesc() 555 | issues = append(issues, &issue) 556 | m.issueMap[issue.IssueKey] = &issue 557 | m.issueIndexMap[issue.IssueKey] = i 558 | } 559 | m.issueList.SetItems(issues) 560 | m.issueList.Title = "Issues" 561 | m.issueList.Styles.Title = m.issueList.Styles.Title.Background(lipgloss.Color(issueListColor)) 562 | m.issuesFetched = true 563 | 564 | return fetchActiveStatus(m.db, 0) 565 | } 566 | 567 | func (m *Model) handleManualEntryInsertedInDBMsg(msg manualWLInsertedInDB) tea.Cmd { 568 | if msg.err != nil { 569 | message := msg.err.Error() 570 | m.message = "Error inserting worklog: " + message 571 | m.messages = append(m.messages, message) 572 | return nil 573 | } 574 | 575 | for i := range m.trackingInputs { 576 | m.trackingInputs[i].SetValue("") 577 | } 578 | return fetchWorkLogs(m.db) 579 | } 580 | 581 | func (m *Model) handleWLUpdatedInDBMsg(msg wLUpdatedInDB) tea.Cmd { 582 | if msg.err != nil { 583 | message := msg.err.Error() 584 | m.message = "Error updating worklog: " + message 585 | m.messages = append(m.messages, message) 586 | return nil 587 | } 588 | 589 | m.message = "Worklog updated" 590 | for i := range m.trackingInputs { 591 | m.trackingInputs[i].SetValue("") 592 | } 593 | return fetchWorkLogs(m.db) 594 | } 595 | 596 | func (m *Model) handleWLEntriesFetchedFromDBMsg(msg wLEntriesFetchedFromDB) { 597 | if msg.err != nil { 598 | message := msg.err.Error() 599 | m.message = message 600 | m.messages = append(m.messages, message) 601 | return 602 | } 603 | 604 | items := make([]list.Item, len(msg.entries)) 605 | var secsSpent int 606 | for i, e := range msg.entries { 607 | secsSpent += e.SecsSpent() 608 | e.FallbackComment = m.fallbackComment 609 | items[i] = list.Item(e) 610 | } 611 | m.worklogList.SetItems(items) 612 | m.unsyncedWLSecsSpent = secsSpent 613 | m.unsyncedWLCount = uint(len(msg.entries)) 614 | if m.debug { 615 | m.message = "[io: log entries]" 616 | } 617 | } 618 | 619 | func (m *Model) handleSyncedWLEntriesFetchedFromDBMsg(msg syncedWLEntriesFetchedFromDB) { 620 | if msg.err != nil { 621 | message := msg.err.Error() 622 | m.message = "Error fetching synced worklog entries: " + message 623 | m.messages = append(m.messages, message) 624 | return 625 | } 626 | 627 | items := make([]list.Item, len(msg.entries)) 628 | for i, e := range msg.entries { 629 | items[i] = list.Item(e) 630 | } 631 | m.syncedWorklogList.SetItems(items) 632 | } 633 | 634 | func (m *Model) handleWLSyncUpdatedInDBMsg(msg wLSyncUpdatedInDB) { 635 | if msg.err != nil { 636 | msg.entry.Error = msg.err 637 | m.messages = append(m.messages, msg.err.Error()) 638 | m.worklogList.SetItem(msg.index, msg.entry) 639 | return 640 | } 641 | 642 | m.unsyncedWLCount-- 643 | m.unsyncedWLSecsSpent -= msg.entry.SecsSpent() 644 | } 645 | 646 | func (m *Model) handleActiveWLFetchedFromDBMsg(msg activeWLFetchedFromDB) { 647 | if msg.err != nil { 648 | message := msg.err.Error() 649 | m.message = message 650 | m.messages = append(m.messages, message) 651 | return 652 | } 653 | 654 | m.activeIssue = msg.activeIssue 655 | if msg.activeIssue == "" { 656 | m.lastChange = updateChange 657 | } else { 658 | m.lastChange = insertChange 659 | activeIssue, ok := m.issueMap[m.activeIssue] 660 | m.activeIssueBeginTS = msg.beginTS 661 | m.activeIssueComment = msg.comment 662 | if ok { 663 | activeIssue.TrackingActive = true 664 | 665 | // go to tracked item on startup 666 | activeIndex, ok := m.issueIndexMap[msg.activeIssue] 667 | if ok { 668 | m.issueList.Select(activeIndex) 669 | } 670 | } 671 | m.trackingActive = true 672 | } 673 | } 674 | 675 | func (m *Model) handleWLDeletedFromDBMsg(msg wLDeletedFromDB) tea.Cmd { 676 | if msg.err != nil { 677 | message := "error deleting entry: " + msg.err.Error() 678 | m.message = message 679 | m.messages = append(m.messages, message) 680 | return nil 681 | } 682 | 683 | return fetchWorkLogs(m.db) 684 | } 685 | 686 | func (m *Model) handleActiveWLDeletedFromDBMsg(msg activeWLDeletedFromDB) { 687 | if msg.err != nil { 688 | m.message = fmt.Sprintf("Error deleting active log entry: %s", msg.err) 689 | return 690 | } 691 | 692 | activeIssue, ok := m.issueMap[m.activeIssue] 693 | if ok { 694 | activeIssue.TrackingActive = false 695 | } 696 | m.lastChange = updateChange 697 | m.trackingActive = false 698 | m.activeIssueComment = nil 699 | m.activeIssue = "" 700 | } 701 | 702 | func (m *Model) handleWLSyncedToJIRAMsg(msg wLSyncedToJIRA) tea.Cmd { 703 | if msg.err != nil { 704 | msg.entry.Error = msg.err 705 | m.messages = append(m.messages, msg.err.Error()) 706 | return nil 707 | } 708 | 709 | msg.entry.Synced = true 710 | msg.entry.SyncInProgress = false 711 | if msg.fallbackCommentUsed { 712 | msg.entry.Comment = m.fallbackComment 713 | } 714 | m.worklogList.SetItem(msg.index, msg.entry) 715 | return updateSyncStatusForEntry(m.db, msg.entry, msg.index, msg.fallbackCommentUsed) 716 | } 717 | 718 | func (m *Model) handleActiveWLUpdatedInDBMsg(msg activeWLUpdatedInDB) { 719 | if msg.err != nil { 720 | message := msg.err.Error() 721 | m.message = message 722 | m.messages = append(m.messages, message) 723 | return 724 | } 725 | 726 | m.activeIssueBeginTS = msg.beginTS 727 | m.activeIssueComment = msg.comment 728 | } 729 | 730 | func (m *Model) handleTrackingToggledInDBMsg(msg trackingToggledInDB) tea.Cmd { 731 | if msg.err != nil { 732 | message := msg.err.Error() 733 | m.message = message 734 | m.messages = append(m.messages, message) 735 | m.trackingActive = false 736 | m.activeIssueComment = nil 737 | return nil 738 | } 739 | 740 | var activeIssue *c.Issue 741 | if msg.activeIssue != "" { 742 | activeIssue = m.issueMap[msg.activeIssue] 743 | } else { 744 | activeIssue = m.issueMap[m.activeIssue] 745 | } 746 | m.changesLocked = false 747 | var cmd tea.Cmd 748 | switch msg.finished { 749 | case true: 750 | m.lastChange = updateChange 751 | if activeIssue != nil { 752 | activeIssue.TrackingActive = false 753 | } 754 | m.trackingActive = false 755 | m.activeIssueComment = nil 756 | cmd = fetchWorkLogs(m.db) 757 | case false: 758 | m.lastChange = insertChange 759 | if activeIssue != nil { 760 | activeIssue.TrackingActive = true 761 | } 762 | m.trackingActive = true 763 | } 764 | 765 | m.activeIssue = msg.activeIssue 766 | return cmd 767 | } 768 | 769 | func (m *Model) handleActiveWLSwitchedInDBMsg(msg activeWLSwitchedInDB) { 770 | if msg.err != nil { 771 | message := msg.err.Error() 772 | m.message = message 773 | m.messages = append(m.messages, message) 774 | if errors.Is(msg.err, pers.ErrNoTaskIsActive) || errors.Is(msg.err, pers.ErrCouldntStartTrackingTask) { 775 | m.trackingActive = false 776 | m.activeIssueComment = nil 777 | } 778 | return 779 | } 780 | 781 | var lastActiveIssue *c.Issue 782 | if msg.lastActiveIssue != "" { 783 | lastActiveIssue = m.issueMap[msg.lastActiveIssue] 784 | if lastActiveIssue != nil { 785 | lastActiveIssue.TrackingActive = false 786 | } 787 | } 788 | 789 | var currentActiveIssue *c.Issue 790 | if msg.currentActiveIssue != "" { 791 | currentActiveIssue = m.issueMap[msg.currentActiveIssue] 792 | } else { 793 | currentActiveIssue = m.issueMap[m.activeIssue] 794 | } 795 | 796 | if currentActiveIssue != nil { 797 | currentActiveIssue.TrackingActive = true 798 | } 799 | m.activeIssue = msg.currentActiveIssue 800 | m.activeIssueBeginTS = msg.beginTS 801 | m.activeIssueComment = nil 802 | } 803 | 804 | func (m *Model) shiftTime(direction timeShiftDirection, duration timeShiftDuration) error { 805 | if m.activeView == editActiveWLView || m.activeView == saveActiveWLView || m.activeView == wlEntryView { 806 | if m.trackingFocussedField == entryBeginTS || m.trackingFocussedField == entryEndTS { 807 | ts, err := time.ParseInLocation(timeFormat, m.trackingInputs[m.trackingFocussedField].Value(), time.Local) 808 | if err != nil { 809 | return err 810 | } 811 | 812 | newTs := getShiftedTime(ts, direction, duration) 813 | 814 | m.trackingInputs[m.trackingFocussedField].SetValue(newTs.Format(timeFormat)) 815 | } 816 | } 817 | return nil 818 | } 819 | -------------------------------------------------------------------------------- /internal/ui/help.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import "fmt" 4 | 5 | var helpText = fmt.Sprintf(` 6 | %s 7 | %s 8 | %s 9 | 10 | %s 11 | %s 12 | %s 13 | %s 14 | %s 15 | %s 16 | %s 17 | %s 18 | %s 19 | %s 20 | %s 21 | %s 22 | `, 23 | helpHeaderStyle.Render("punchout Reference Manual"), 24 | helpSectionStyle.Render(` 25 | (scroll line by line with j/k/arrow keys or by half a page with /) 26 | 27 | punchout has 5 panes: 28 | - Issues List View Shows you issues matching your JQL query 29 | - Worklog List View Shows you your worklog entries; you sync these entries 30 | to JIRA from here 31 | - Worklog Entry/Update View You enter/update a worklog entry from here 32 | - Synced Worklog List View You view the worklog entries synced to JIRA here 33 | - Help View (this one) 34 | `), 35 | helpHeaderStyle.Render("Keyboard Shortcuts"), 36 | helpHeaderStyle.Render("General"), 37 | helpSectionStyle.Render(` 38 | 1 Switch to Issues List View 39 | 2 Switch to Worklog List View 40 | 3 Switch to Synced Worklog List View 41 | Go to next view/form entry 42 | Go to previous view/form entry 43 | q/ Go back/reset filtering/quit 44 | Cancel form/quit 45 | ? Show help view 46 | `), 47 | helpHeaderStyle.Render("General List Controls"), 48 | helpSectionStyle.Render(` 49 | k/ Move cursor up 50 | j/ Move cursor down 51 | h Go to previous page 52 | l Go to next page 53 | / Start filtering 54 | `), 55 | helpHeaderStyle.Render("Issue List View"), 56 | helpSectionStyle.Render(` 57 | s Toggle recording time on the currently selected issue, 58 | will open up a form to record a comment on the second 59 | "s" keypress 60 | S Quick switch recording; will save a worklog entry without 61 | a comment for the currently active issue, and start 62 | recording time for another issue 63 | Update active worklog entry (when tracking active), or add 64 | manual worklog entry (when not tracking) 65 | Go to currently tracked item 66 | Discard currently active recording 67 | Open issue in browser 68 | `), 69 | helpHeaderStyle.Render("Worklog List View"), 70 | helpSectionStyle.Render(` 71 | /u Update worklog entry 72 | Delete worklog entry 73 | s Sync all visible entries to JIRA 74 | Refresh list 75 | `), 76 | helpHeaderStyle.Render("Worklog Entry/Update View"), 77 | helpSectionStyle.Render(` 78 | enter Save worklog entry 79 | k Move timestamp backwards by one minute 80 | j Move timestamp forwards by one minute 81 | K Move timestamp backwards by five minutes 82 | J Move timestamp forwards by five minutes 83 | h Move timestamp backwards by a day 84 | l Move timestamp forwards by a day 85 | ctrl+s Sync timestamp under cursor with the other (when 86 | applicable) 87 | `), 88 | helpHeaderStyle.Render("Synced Worklog List View"), 89 | helpSectionStyle.Render(` 90 | Refresh list 91 | `), 92 | ) 93 | -------------------------------------------------------------------------------- /internal/ui/initial.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "database/sql" 5 | 6 | jira "github.com/andygrunwald/go-jira/v2/onpremise" 7 | "github.com/charmbracelet/bubbles/list" 8 | "github.com/charmbracelet/bubbles/textinput" 9 | "github.com/charmbracelet/lipgloss" 10 | c "github.com/dhth/punchout/internal/common" 11 | ) 12 | 13 | func InitialModel(db *sql.DB, jiraClient *jira.Client, installationType JiraInstallationType, jql string, jiraTimeDeltaMins int, fallbackComment *string, debug bool) Model { 14 | var stackItems []list.Item 15 | var worklogListItems []list.Item 16 | var syncedWorklogListItems []list.Item 17 | 18 | trackingInputs := make([]textinput.Model, 3) 19 | trackingInputs[entryBeginTS] = textinput.New() 20 | trackingInputs[entryBeginTS].Placeholder = "09:30" 21 | trackingInputs[entryBeginTS].Focus() 22 | trackingInputs[entryBeginTS].CharLimit = len(string(timeFormat)) 23 | trackingInputs[entryBeginTS].Width = 30 24 | 25 | trackingInputs[entryEndTS] = textinput.New() 26 | trackingInputs[entryEndTS].Placeholder = "12:30pm" 27 | trackingInputs[entryEndTS].Focus() 28 | trackingInputs[entryEndTS].CharLimit = len(string(timeFormat)) 29 | trackingInputs[entryEndTS].Width = 30 30 | 31 | trackingInputs[entryComment] = textinput.New() 32 | trackingInputs[entryComment].Placeholder = "Your comment goes here" 33 | trackingInputs[entryComment].Focus() 34 | trackingInputs[entryComment].CharLimit = 255 35 | trackingInputs[entryComment].Width = 60 36 | 37 | m := Model{ 38 | db: db, 39 | jiraClient: jiraClient, 40 | installationType: installationType, 41 | jql: jql, 42 | fallbackComment: fallbackComment, 43 | issueList: list.New(stackItems, newItemDelegate(lipgloss.Color(issueListColor)), listWidth, 0), 44 | issueMap: make(map[string]*c.Issue), 45 | issueIndexMap: make(map[string]int), 46 | worklogList: list.New(worklogListItems, newItemDelegate(lipgloss.Color(worklogListColor)), listWidth, 0), 47 | syncedWorklogList: list.New(syncedWorklogListItems, newItemDelegate(syncedWorklogListColor), listWidth, 0), 48 | jiraTimeDeltaMins: jiraTimeDeltaMins, 49 | showHelpIndicator: true, 50 | trackingInputs: trackingInputs, 51 | debug: debug, 52 | } 53 | m.issueList.Title = "fetching..." 54 | m.issueList.SetStatusBarItemName("issue", "issues") 55 | m.issueList.DisableQuitKeybindings() 56 | m.issueList.SetShowHelp(false) 57 | m.issueList.Styles.Title = m.issueList.Styles.Title.Foreground(lipgloss.Color(c.DefaultBackgroundColor)). 58 | Background(lipgloss.Color(issueListUnfetchedColor)). 59 | Bold(true) 60 | 61 | m.worklogList.Title = "Worklog Entries" 62 | m.worklogList.SetStatusBarItemName("entry", "entries") 63 | m.worklogList.SetFilteringEnabled(false) 64 | m.worklogList.DisableQuitKeybindings() 65 | m.worklogList.SetShowHelp(false) 66 | m.worklogList.Styles.Title = m.worklogList.Styles.Title.Foreground(lipgloss.Color(c.DefaultBackgroundColor)). 67 | Background(lipgloss.Color(worklogListColor)). 68 | Bold(true) 69 | 70 | m.syncedWorklogList.Title = "Synced Worklog Entries (from local db)" 71 | m.syncedWorklogList.SetStatusBarItemName("entry", "entries") 72 | m.syncedWorklogList.SetFilteringEnabled(false) 73 | m.syncedWorklogList.DisableQuitKeybindings() 74 | m.syncedWorklogList.SetShowHelp(false) 75 | m.syncedWorklogList.Styles.Title = m.syncedWorklogList.Styles.Title.Foreground(lipgloss.Color(c.DefaultBackgroundColor)). 76 | Background(lipgloss.Color(syncedWorklogListColor)). 77 | Bold(true) 78 | 79 | return m 80 | } 81 | -------------------------------------------------------------------------------- /internal/ui/issue_delegate.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "github.com/charmbracelet/bubbles/list" 5 | "github.com/charmbracelet/lipgloss" 6 | ) 7 | 8 | func newItemDelegate(color lipgloss.Color) list.DefaultDelegate { 9 | d := list.NewDefaultDelegate() 10 | 11 | d.Styles.SelectedTitle = d.Styles. 12 | SelectedTitle. 13 | Foreground(color). 14 | BorderLeftForeground(color) 15 | d.Styles.SelectedDesc = d.Styles. 16 | SelectedTitle 17 | 18 | return d 19 | } 20 | -------------------------------------------------------------------------------- /internal/ui/jira.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "time" 7 | 8 | jira "github.com/andygrunwald/go-jira/v2/onpremise" 9 | ) 10 | 11 | var errJIRARepliedWithEmptyWorklog = errors.New("JIRA replied with an empty worklog; something is probably wrong") 12 | 13 | func getIssues(cl *jira.Client, jql string) ([]jira.Issue, int, error) { 14 | issues, resp, err := cl.Issue.Search(context.Background(), jql, nil) 15 | return issues, resp.StatusCode, err 16 | } 17 | 18 | func syncWLToJIRA(cl *jira.Client, issueKey string, beginTS, endTS time.Time, comment string, timeDeltaMins int) error { 19 | start := beginTS 20 | 21 | if timeDeltaMins != 0 { 22 | start = start.Add(time.Minute * time.Duration(timeDeltaMins)) 23 | } 24 | 25 | timeSpentSecs := int(endTS.Sub(beginTS).Seconds()) 26 | wl := jira.WorklogRecord{ 27 | IssueID: issueKey, 28 | Started: (*jira.Time)(&start), 29 | TimeSpentSeconds: timeSpentSecs, 30 | Comment: comment, 31 | } 32 | cwl, _, err := cl.Issue.AddWorklogRecord(context.Background(), 33 | issueKey, 34 | &wl, 35 | ) 36 | 37 | if cwl != nil && cwl.Started == nil { 38 | return errJIRARepliedWithEmptyWorklog 39 | } 40 | return err 41 | } 42 | -------------------------------------------------------------------------------- /internal/ui/model.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "database/sql" 5 | "time" 6 | 7 | jira "github.com/andygrunwald/go-jira/v2/onpremise" 8 | "github.com/charmbracelet/bubbles/list" 9 | "github.com/charmbracelet/bubbles/textinput" 10 | "github.com/charmbracelet/bubbles/viewport" 11 | tea "github.com/charmbracelet/bubbletea" 12 | c "github.com/dhth/punchout/internal/common" 13 | ) 14 | 15 | type JiraInstallationType uint 16 | 17 | const ( 18 | OnPremiseInstallation JiraInstallationType = iota 19 | CloudInstallation 20 | ) 21 | 22 | type trackingStatus uint 23 | 24 | const ( 25 | trackingInactive trackingStatus = iota 26 | trackingActive 27 | ) 28 | 29 | type dBChange uint 30 | 31 | const ( 32 | insertChange dBChange = iota 33 | updateChange 34 | ) 35 | 36 | type stateView uint 37 | 38 | const ( 39 | issueListView stateView = iota // shows issues 40 | wLView // shows worklogs that aren't yet synced 41 | syncedWLView // shows worklogs that are synced 42 | editActiveWLView // edit the active worklog 43 | saveActiveWLView // finish the active worklog 44 | wlEntryView // for saving manual worklog, or for updating a saved worklog 45 | helpView 46 | ) 47 | 48 | type trackingFocussedField uint 49 | 50 | const ( 51 | entryBeginTS trackingFocussedField = iota 52 | entryEndTS 53 | entryComment 54 | ) 55 | 56 | type worklogSaveType uint 57 | 58 | const ( 59 | worklogInsert worklogSaveType = iota 60 | worklogUpdate 61 | ) 62 | 63 | const ( 64 | timeFormat = "2006/01/02 15:04" 65 | dayAndTimeFormat = "Mon, 15:04" 66 | dateFormat = "2006/01/02" 67 | timeOnlyFormat = "15:04" 68 | ) 69 | 70 | type Model struct { 71 | activeView stateView 72 | lastView stateView 73 | db *sql.DB 74 | jiraClient *jira.Client 75 | installationType JiraInstallationType 76 | jql string 77 | fallbackComment *string 78 | issueList list.Model 79 | issueMap map[string]*c.Issue 80 | issueIndexMap map[string]int 81 | issuesFetched bool 82 | worklogList list.Model 83 | unsyncedWLCount uint 84 | unsyncedWLSecsSpent int 85 | syncedWorklogList list.Model 86 | activeIssueBeginTS time.Time 87 | activeIssueEndTS time.Time 88 | activeIssueComment *string 89 | trackingInputs []textinput.Model 90 | trackingFocussedField trackingFocussedField 91 | helpVP viewport.Model 92 | helpVPReady bool 93 | lastChange dBChange 94 | changesLocked bool 95 | activeIssue string 96 | worklogSaveType worklogSaveType 97 | message string 98 | messages []string 99 | jiraTimeDeltaMins int 100 | showHelpIndicator bool 101 | terminalHeight int 102 | trackingActive bool 103 | debug bool 104 | } 105 | 106 | func (m Model) Init() tea.Cmd { 107 | return tea.Batch( 108 | hideHelp(time.Minute*1), 109 | fetchJIRAIssues(m.jiraClient, m.jql), 110 | fetchWorkLogs(m.db), 111 | fetchSyncedWorkLogs(m.db), 112 | ) 113 | } 114 | -------------------------------------------------------------------------------- /internal/ui/msgs.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "time" 5 | 6 | c "github.com/dhth/punchout/internal/common" 7 | ) 8 | 9 | type hideHelpMsg struct{} 10 | 11 | type trackingToggledInDB struct { 12 | activeIssue string 13 | finished bool 14 | err error 15 | } 16 | 17 | type activeWLSwitchedInDB struct { 18 | lastActiveIssue string 19 | currentActiveIssue string 20 | beginTS time.Time 21 | err error 22 | } 23 | 24 | type activeWLUpdatedInDB struct { 25 | beginTS time.Time 26 | comment *string 27 | err error 28 | } 29 | 30 | type manualWLInsertedInDB struct { 31 | issueKey string 32 | err error 33 | } 34 | 35 | type activeWLDeletedFromDB struct { 36 | err error 37 | } 38 | 39 | type wLUpdatedInDB struct { 40 | rowID int 41 | issueKey string 42 | err error 43 | } 44 | 45 | type activeWLFetchedFromDB struct { 46 | activeIssue string 47 | beginTS time.Time 48 | comment *string 49 | err error 50 | } 51 | 52 | type wLEntriesFetchedFromDB struct { 53 | entries []c.WorklogEntry 54 | err error 55 | } 56 | 57 | type syncedWLEntriesFetchedFromDB struct { 58 | entries []c.SyncedWorklogEntry 59 | err error 60 | } 61 | 62 | type wLDeletedFromDB struct { 63 | err error 64 | } 65 | 66 | type wLSyncUpdatedInDB struct { 67 | entry c.WorklogEntry 68 | index int 69 | err error 70 | } 71 | 72 | type issuesFetchedFromJIRA struct { 73 | issues []c.Issue 74 | responseStatusCode int 75 | err error 76 | } 77 | 78 | type wLSyncedToJIRA struct { 79 | index int 80 | entry c.WorklogEntry 81 | fallbackCommentUsed bool 82 | err error 83 | } 84 | 85 | type urlOpenedinBrowserMsg struct { 86 | url string 87 | err error 88 | } 89 | -------------------------------------------------------------------------------- /internal/ui/styles.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "github.com/charmbracelet/lipgloss" 5 | c "github.com/dhth/punchout/internal/common" 6 | ) 7 | 8 | const ( 9 | issueListUnfetchedColor = "#928374" 10 | failureColor = "#fb4934" 11 | issueListColor = "#fe8019" 12 | worklogListColor = "#fabd2f" 13 | syncedWorklogListColor = "#b8bb26" 14 | trackingColor = "#fe8019" 15 | unsyncedCountColor = "#fabd2f" 16 | activeIssueKeyColor = "#d3869b" 17 | activeIssueSummaryColor = "#8ec07c" 18 | trackingBeganColor = "#fabd2f" 19 | toolNameColor = "#b8bb26" 20 | formFieldNameColor = "#8ec07c" 21 | formContextColor = "#fabd2f" 22 | formHelpColor = "#928374" 23 | initialHelpMsgColor = "#83a598" 24 | helpMsgColor = "#7c6f64" 25 | helpViewTitleColor = "#83a598" 26 | helpHeaderColor = "#83a598" 27 | helpSectionColor = "#fabd2f" 28 | ) 29 | 30 | var ( 31 | helpMsgStyle = lipgloss.NewStyle(). 32 | PaddingLeft(2). 33 | Bold(true). 34 | Foreground(lipgloss.Color(helpMsgColor)) 35 | 36 | baseListStyle = lipgloss.NewStyle(). 37 | PaddingTop(1). 38 | PaddingRight(2). 39 | PaddingBottom(1) 40 | 41 | viewPortStyle = lipgloss.NewStyle(). 42 | PaddingTop(1). 43 | PaddingRight(2). 44 | PaddingBottom(1) 45 | 46 | listStyle = baseListStyle 47 | 48 | modeStyle = c.BaseStyle. 49 | Align(lipgloss.Center). 50 | Bold(true). 51 | Background(lipgloss.Color(toolNameColor)) 52 | 53 | baseHeadingStyle = lipgloss.NewStyle(). 54 | Bold(true). 55 | PaddingLeft(1). 56 | PaddingRight(1). 57 | Foreground(lipgloss.Color(c.DefaultBackgroundColor)) 58 | 59 | workLogEntryHeadingStyle = baseHeadingStyle. 60 | Background(lipgloss.Color(worklogListColor)) 61 | 62 | formContextStyle = lipgloss.NewStyle(). 63 | Foreground(lipgloss.Color(formContextColor)) 64 | 65 | formFieldNameStyle = lipgloss.NewStyle(). 66 | Foreground(lipgloss.Color(formFieldNameColor)) 67 | 68 | formHelpStyle = lipgloss.NewStyle(). 69 | Foreground(lipgloss.Color(formHelpColor)) 70 | 71 | trackingStyle = lipgloss.NewStyle(). 72 | PaddingLeft(2). 73 | Bold(true). 74 | Foreground(lipgloss.Color(trackingColor)) 75 | 76 | activeIssueKeyMsgStyle = trackingStyle. 77 | PaddingLeft(1). 78 | Foreground(lipgloss.Color(activeIssueKeyColor)) 79 | 80 | activeIssueSummaryMsgStyle = trackingStyle. 81 | PaddingLeft(1). 82 | Foreground(lipgloss.Color(activeIssueSummaryColor)) 83 | 84 | trackingBeganStyle = trackingStyle. 85 | PaddingLeft(1). 86 | Foreground(lipgloss.Color(trackingBeganColor)) 87 | 88 | unsyncedCountStyle = lipgloss.NewStyle(). 89 | PaddingLeft(2). 90 | Bold(true). 91 | Foreground(lipgloss.Color(unsyncedCountColor)) 92 | 93 | initialHelpMsgStyle = helpMsgStyle. 94 | Foreground(lipgloss.Color(initialHelpMsgColor)) 95 | 96 | helpTitleStyle = c.BaseStyle. 97 | Bold(true). 98 | Background(lipgloss.Color(helpViewTitleColor)). 99 | Align(lipgloss.Left) 100 | 101 | helpHeaderStyle = lipgloss.NewStyle(). 102 | Bold(true). 103 | Foreground(lipgloss.Color(helpHeaderColor)) 104 | 105 | helpSectionStyle = lipgloss.NewStyle(). 106 | Foreground(lipgloss.Color(helpSectionColor)) 107 | ) 108 | -------------------------------------------------------------------------------- /internal/ui/ui.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "database/sql" 5 | "os" 6 | 7 | jira "github.com/andygrunwald/go-jira/v2/onpremise" 8 | tea "github.com/charmbracelet/bubbletea" 9 | ) 10 | 11 | func RenderUI(db *sql.DB, jiraClient *jira.Client, installationType JiraInstallationType, jql string, jiraTimeDeltaMins int, fallbackComment *string) error { 12 | debug := os.Getenv("DEBUG") == "1" 13 | if debug { 14 | f, err := tea.LogToFile("debug.log", "debug") 15 | if err != nil { 16 | return err 17 | } 18 | defer f.Close() 19 | } 20 | 21 | p := tea.NewProgram(InitialModel(db, jiraClient, installationType, jql, jiraTimeDeltaMins, fallbackComment, debug), tea.WithAltScreen()) 22 | if _, err := p.Run(); err != nil { 23 | return err 24 | } 25 | 26 | return nil 27 | } 28 | -------------------------------------------------------------------------------- /internal/ui/update.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/charmbracelet/bubbles/list" 7 | tea "github.com/charmbracelet/bubbletea" 8 | ) 9 | 10 | func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 11 | var cmd tea.Cmd 12 | var cmds []tea.Cmd 13 | m.message = "" 14 | 15 | switch msg := msg.(type) { 16 | case tea.KeyMsg: 17 | if m.issueList.FilterState() == list.Filtering { 18 | m.issueList, cmd = m.issueList.Update(msg) 19 | cmds = append(cmds, cmd) 20 | return m, tea.Batch(cmds...) 21 | } 22 | } 23 | 24 | switch msg := msg.(type) { 25 | case tea.KeyMsg: 26 | switch msg.String() { 27 | case "enter": 28 | var saveCmd tea.Cmd 29 | var ret bool 30 | switch m.activeView { 31 | case editActiveWLView: 32 | saveCmd = m.getCmdToUpdateActiveWL() 33 | ret = true 34 | case saveActiveWLView: 35 | saveCmd = m.getCmdToSaveActiveWL() 36 | ret = true 37 | case wlEntryView: 38 | saveCmd = m.getCmdToSaveOrUpdateWL() 39 | ret = true 40 | } 41 | if saveCmd != nil { 42 | cmds = append(cmds, saveCmd) 43 | } 44 | if ret { 45 | return m, tea.Batch(cmds...) 46 | } 47 | case "ctrl+s": 48 | switch m.activeView { 49 | case saveActiveWLView, wlEntryView: 50 | m.handleRequestToSyncTimestamps() 51 | } 52 | case "esc": 53 | quit := m.handleEscape() 54 | if quit { 55 | return m, tea.Quit 56 | } 57 | case "tab": 58 | viewSwitchCmd := m.getCmdToGoForwardsInViews() 59 | if viewSwitchCmd != nil { 60 | cmds = append(cmds, viewSwitchCmd) 61 | } 62 | case "shift+tab": 63 | viewSwitchCmd := m.getCmdToGoBackwardsInViews() 64 | if viewSwitchCmd != nil { 65 | cmds = append(cmds, viewSwitchCmd) 66 | } 67 | case "k": 68 | err := m.shiftTime(shiftBackward, shiftMinute) 69 | if err != nil { 70 | return m, tea.Batch(cmds...) 71 | } 72 | case "j": 73 | err := m.shiftTime(shiftForward, shiftMinute) 74 | if err != nil { 75 | return m, tea.Batch(cmds...) 76 | } 77 | case "K": 78 | err := m.shiftTime(shiftBackward, shiftFiveMinutes) 79 | if err != nil { 80 | return m, tea.Batch(cmds...) 81 | } 82 | case "J": 83 | err := m.shiftTime(shiftForward, shiftFiveMinutes) 84 | if err != nil { 85 | return m, tea.Batch(cmds...) 86 | } 87 | case "h": 88 | err := m.shiftTime(shiftBackward, shiftDay) 89 | if err != nil { 90 | return m, tea.Batch(cmds...) 91 | } 92 | case "l": 93 | err := m.shiftTime(shiftForward, shiftDay) 94 | if err != nil { 95 | return m, tea.Batch(cmds...) 96 | } 97 | } 98 | } 99 | 100 | switch m.activeView { 101 | case editActiveWLView, saveActiveWLView, wlEntryView: 102 | for i := range m.trackingInputs { 103 | m.trackingInputs[i], cmd = m.trackingInputs[i].Update(msg) 104 | cmds = append(cmds, cmd) 105 | } 106 | return m, tea.Batch(cmds...) 107 | } 108 | 109 | switch msg := msg.(type) { 110 | case tea.KeyMsg: 111 | switch msg.String() { 112 | case "ctrl+c", "q": 113 | quit := m.handleRequestToGoBackOrQuit() 114 | if quit { 115 | return m, tea.Quit 116 | } 117 | case "1": 118 | if m.activeView != issueListView { 119 | m.activeView = issueListView 120 | } 121 | case "2": 122 | if m.activeView != wLView { 123 | m.activeView = wLView 124 | cmds = append(cmds, fetchWorkLogs(m.db)) 125 | } 126 | case "3": 127 | if m.activeView != syncedWLView { 128 | m.activeView = syncedWLView 129 | } 130 | case "ctrl+r": 131 | reloadCmd := m.getCmdToReloadData() 132 | if reloadCmd != nil { 133 | cmds = append(cmds, reloadCmd) 134 | } 135 | case "ctrl+t": 136 | m.handleRequestToGoToActiveIssue() 137 | case "ctrl+s": 138 | if !m.issuesFetched { 139 | break 140 | } 141 | 142 | switch m.activeView { 143 | case issueListView: 144 | switch m.trackingActive { 145 | case true: 146 | m.handleRequestToUpdateActiveWL() 147 | case false: 148 | m.handleRequestToCreateManualWL() 149 | } 150 | case wLView: 151 | m.handleRequestToUpdateSavedWL() 152 | } 153 | 154 | case "u": 155 | if m.activeView != wLView { 156 | break 157 | } 158 | m.handleRequestToUpdateSavedWL() 159 | 160 | case "ctrl+d": 161 | switch m.activeView { 162 | case wLView: 163 | deleteCmd := m.getCmdToDeleteWL() 164 | if deleteCmd != nil { 165 | cmds = append(cmds, deleteCmd) 166 | } 167 | } 168 | case "ctrl+x": 169 | if m.activeView == issueListView && m.trackingActive { 170 | cmds = append(cmds, deleteActiveIssueLog(m.db)) 171 | } 172 | case "S": 173 | if m.activeView != issueListView { 174 | break 175 | } 176 | quickSwitchCmd := m.getCmdToQuickSwitchTracking() 177 | if quickSwitchCmd != nil { 178 | cmds = append(cmds, quickSwitchCmd) 179 | } 180 | 181 | case "s": 182 | if !m.issuesFetched { 183 | break 184 | } 185 | 186 | switch m.activeView { 187 | case issueListView: 188 | handleCmd := m.getCmdToToggleTracking() 189 | if handleCmd != nil { 190 | cmds = append(cmds, handleCmd) 191 | } 192 | case wLView: 193 | syncCmds := m.getCmdToSyncWLToJIRA() 194 | if len(syncCmds) > 0 { 195 | cmds = append(cmds, syncCmds...) 196 | } 197 | } 198 | case "?": 199 | if m.activeView == issueListView || m.activeView == wLView || m.activeView == syncedWLView { 200 | m.lastView = m.activeView 201 | m.activeView = helpView 202 | } 203 | case "ctrl+b": 204 | if !m.issuesFetched { 205 | break 206 | } 207 | 208 | if m.activeView == issueListView { 209 | cmds = append(cmds, m.getCmdToOpenIssueInBrowser()) 210 | } 211 | } 212 | 213 | case tea.WindowSizeMsg: 214 | m.handleWindowResizing(msg) 215 | case issuesFetchedFromJIRA: 216 | handleCmd := m.handleIssuesFetchedFromJIRAMsg(msg) 217 | if handleCmd != nil { 218 | cmds = append(cmds, handleCmd) 219 | } 220 | case manualWLInsertedInDB: 221 | handleCmd := m.handleManualEntryInsertedInDBMsg(msg) 222 | if handleCmd != nil { 223 | cmds = append(cmds, handleCmd) 224 | } 225 | case wLUpdatedInDB: 226 | handleCmd := m.handleWLUpdatedInDBMsg(msg) 227 | if handleCmd != nil { 228 | cmds = append(cmds, handleCmd) 229 | } 230 | case wLEntriesFetchedFromDB: 231 | m.handleWLEntriesFetchedFromDBMsg(msg) 232 | case syncedWLEntriesFetchedFromDB: 233 | m.handleSyncedWLEntriesFetchedFromDBMsg(msg) 234 | case wLSyncUpdatedInDB: 235 | m.handleWLSyncUpdatedInDBMsg(msg) 236 | case activeWLFetchedFromDB: 237 | m.handleActiveWLFetchedFromDBMsg(msg) 238 | case wLDeletedFromDB: 239 | handleCmd := m.handleWLDeletedFromDBMsg(msg) 240 | if handleCmd != nil { 241 | cmds = append(cmds, handleCmd) 242 | } 243 | case activeWLDeletedFromDB: 244 | m.handleActiveWLDeletedFromDBMsg(msg) 245 | case wLSyncedToJIRA: 246 | handleCmd := m.handleWLSyncedToJIRAMsg(msg) 247 | if handleCmd != nil { 248 | cmds = append(cmds, handleCmd) 249 | } 250 | case activeWLUpdatedInDB: 251 | m.handleActiveWLUpdatedInDBMsg(msg) 252 | case trackingToggledInDB: 253 | handleCmd := m.handleTrackingToggledInDBMsg(msg) 254 | if handleCmd != nil { 255 | cmds = append(cmds, handleCmd) 256 | } 257 | case activeWLSwitchedInDB: 258 | m.handleActiveWLSwitchedInDBMsg(msg) 259 | case hideHelpMsg: 260 | m.showHelpIndicator = false 261 | case urlOpenedinBrowserMsg: 262 | if msg.err != nil { 263 | m.message = fmt.Sprintf("Error opening url: %s", msg.err.Error()) 264 | } 265 | } 266 | 267 | switch m.activeView { 268 | case issueListView: 269 | m.issueList, cmd = m.issueList.Update(msg) 270 | cmds = append(cmds, cmd) 271 | case wLView: 272 | m.worklogList, cmd = m.worklogList.Update(msg) 273 | cmds = append(cmds, cmd) 274 | case syncedWLView: 275 | m.syncedWorklogList, cmd = m.syncedWorklogList.Update(msg) 276 | cmds = append(cmds, cmd) 277 | case helpView: 278 | m.helpVP, cmd = m.helpVP.Update(msg) 279 | cmds = append(cmds, cmd) 280 | } 281 | 282 | return m, tea.Batch(cmds...) 283 | } 284 | -------------------------------------------------------------------------------- /internal/ui/view.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/charmbracelet/lipgloss" 7 | c "github.com/dhth/punchout/internal/common" 8 | ) 9 | 10 | var listWidth = 140 11 | 12 | func (m Model) View() string { 13 | var content string 14 | var footer string 15 | 16 | var statusBar string 17 | var helpMsg string 18 | if m.message != "" { 19 | statusBar = c.Trim(m.message, 120) 20 | } 21 | var activeMsg string 22 | 23 | var fallbackCommentMsg string 24 | if m.fallbackComment != nil { 25 | fallbackCommentMsg = " (a fallback is configured)" 26 | } 27 | 28 | if m.issuesFetched { 29 | if m.activeIssue != "" { 30 | var issueSummaryMsg, trackingSinceMsg string 31 | issue, ok := m.issueMap[m.activeIssue] 32 | if ok { 33 | issueSummaryMsg = fmt.Sprintf("(%s)", c.Trim(issue.Summary, 50)) 34 | if m.activeView != saveActiveWLView { 35 | trackingSinceMsg = fmt.Sprintf("(since %s)", m.activeIssueBeginTS.Format(timeOnlyFormat)) 36 | } 37 | } 38 | activeMsg = fmt.Sprintf("%s%s%s%s", 39 | trackingStyle.Render("tracking:"), 40 | activeIssueKeyMsgStyle.Render(m.activeIssue), 41 | activeIssueSummaryMsgStyle.Render(issueSummaryMsg), 42 | trackingBeganStyle.Render(trackingSinceMsg), 43 | ) 44 | } 45 | 46 | if m.showHelpIndicator { 47 | // first time help 48 | if m.activeView == issueListView && len(m.syncedWorklogList.Items()) == 0 && m.unsyncedWLCount == 0 { 49 | if m.trackingActive { 50 | helpMsg += initialHelpMsgStyle.Render("Press s to stop tracking time") 51 | } else { 52 | helpMsg += initialHelpMsgStyle.Render("Press s to start tracking time") 53 | } 54 | } 55 | } 56 | } 57 | 58 | formHeadingText := "Enter/update the following details:" 59 | formHelp := "Use tab/shift-tab to move between sections; esc to go back." 60 | formBeginTimeHelp := "Begin Time* (format: 2006/01/02 15:04)" 61 | formEndTimeHelp := "End Time* (format: 2006/01/02 15:04)" 62 | formTimeShiftHelp := "(k/j/K/J moves time, when correct)" 63 | formCommentHelp := fmt.Sprintf("Comment%s", fallbackCommentMsg) 64 | formSubmitHelp := "Press enter to submit" 65 | 66 | switch m.activeView { 67 | case issueListView: 68 | content = listStyle.Render(m.issueList.View()) 69 | case wLView: 70 | content = listStyle.Render(m.worklogList.View()) 71 | case syncedWLView: 72 | content = listStyle.Render(m.syncedWorklogList.View()) 73 | case editActiveWLView: 74 | content = fmt.Sprintf( 75 | ` 76 | %s 77 | 78 | %s 79 | 80 | %s 81 | 82 | %s 83 | 84 | %s %s 85 | 86 | %s 87 | 88 | %s 89 | 90 | 91 | %s 92 | `, 93 | workLogEntryHeadingStyle.Render("Edit Active Worklog"), 94 | formContextStyle.Render(formHeadingText), 95 | formHelpStyle.Render(formHelp), 96 | formFieldNameStyle.Render(formBeginTimeHelp), 97 | m.trackingInputs[entryBeginTS].View(), 98 | formHelpStyle.Render(formTimeShiftHelp), 99 | formFieldNameStyle.Render(formCommentHelp), 100 | m.trackingInputs[entryComment].View(), 101 | formContextStyle.Render(formSubmitHelp), 102 | ) 103 | for i := 0; i < m.terminalHeight-20; i++ { 104 | content += "\n" 105 | } 106 | case saveActiveWLView: 107 | content = fmt.Sprintf( 108 | ` 109 | %s 110 | 111 | %s 112 | 113 | %s 114 | 115 | %s 116 | 117 | %s %s 118 | 119 | %s 120 | 121 | %s %s 122 | 123 | %s 124 | 125 | %s 126 | 127 | 128 | %s 129 | `, 130 | workLogEntryHeadingStyle.Render("Save Worklog"), 131 | formContextStyle.Render(formHeadingText), 132 | formHelpStyle.Render(formHelp), 133 | formFieldNameStyle.Render(formBeginTimeHelp), 134 | m.trackingInputs[entryBeginTS].View(), 135 | formHelpStyle.Render(formTimeShiftHelp), 136 | formFieldNameStyle.Render(formEndTimeHelp), 137 | m.trackingInputs[entryEndTS].View(), 138 | formHelpStyle.Render(formTimeShiftHelp), 139 | formFieldNameStyle.Render(formCommentHelp), 140 | m.trackingInputs[entryComment].View(), 141 | formContextStyle.Render(formSubmitHelp), 142 | ) 143 | for i := 0; i < m.terminalHeight-24; i++ { 144 | content += "\n" 145 | } 146 | case wlEntryView: 147 | var formHeading string 148 | switch m.worklogSaveType { 149 | case worklogInsert: 150 | formHeading = "Save Worklog (manual)" 151 | case worklogUpdate: 152 | formHeading = "Update Worklog" 153 | } 154 | 155 | content = fmt.Sprintf( 156 | ` 157 | %s 158 | 159 | %s 160 | 161 | %s 162 | 163 | %s 164 | 165 | %s %s 166 | 167 | %s 168 | 169 | %s %s 170 | 171 | %s 172 | 173 | %s 174 | 175 | 176 | %s 177 | `, 178 | workLogEntryHeadingStyle.Render(formHeading), 179 | formContextStyle.Render(formHeadingText), 180 | formHelpStyle.Render(formHelp), 181 | formFieldNameStyle.Render(formBeginTimeHelp), 182 | m.trackingInputs[entryBeginTS].View(), 183 | formHelpStyle.Render(formTimeShiftHelp), 184 | formFieldNameStyle.Render(formEndTimeHelp), 185 | m.trackingInputs[entryEndTS].View(), 186 | formHelpStyle.Render(formTimeShiftHelp), 187 | formFieldNameStyle.Render(formCommentHelp), 188 | m.trackingInputs[entryComment].View(), 189 | formContextStyle.Render(formSubmitHelp), 190 | ) 191 | for i := 0; i < m.terminalHeight-24; i++ { 192 | content += "\n" 193 | } 194 | case helpView: 195 | if !m.helpVPReady { 196 | content = "\n Initializing..." 197 | } else { 198 | content = viewPortStyle.Render(fmt.Sprintf(" %s\n\n%s\n", helpTitleStyle.Render("Help"), m.helpVP.View())) 199 | } 200 | } 201 | 202 | footerStyle := lipgloss.NewStyle(). 203 | Foreground(lipgloss.Color("#282828")). 204 | Background(lipgloss.Color("#7c6f64")) 205 | 206 | if m.showHelpIndicator { 207 | helpMsg += helpMsgStyle.Render("Press ? for help") 208 | } 209 | 210 | var unsyncedMsg string 211 | if m.unsyncedWLCount > 0 { 212 | entryWord := "entries" 213 | if m.unsyncedWLCount == 1 { 214 | entryWord = "entry" 215 | } 216 | unsyncedTimeMsg := c.HumanizeDuration(m.unsyncedWLSecsSpent) 217 | unsyncedMsg = unsyncedCountStyle.Render(fmt.Sprintf("%d unsynced %s (%s)", m.unsyncedWLCount, entryWord, unsyncedTimeMsg)) 218 | } 219 | 220 | footerStr := fmt.Sprintf("%s%s%s%s", 221 | modeStyle.Render("punchout"), 222 | helpMsg, 223 | unsyncedMsg, 224 | activeMsg, 225 | ) 226 | footer = footerStyle.Render(footerStr) 227 | 228 | return lipgloss.JoinVertical(lipgloss.Left, 229 | content, 230 | statusBar, 231 | footer, 232 | ) 233 | } 234 | -------------------------------------------------------------------------------- /punch.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/dhth/punchout/cmd" 8 | ) 9 | 10 | func main() { 11 | err := cmd.Execute() 12 | if err != nil { 13 | fmt.Fprintf(os.Stderr, "Error: %s\n", err.Error()) 14 | os.Exit(1) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /punchout.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dhth/punchout/ca5c79f20e4ce575fdf901c4bb6953266e981e4a/punchout.gif -------------------------------------------------------------------------------- /tests/config-bad.toml: -------------------------------------------------------------------------------- 1 | [jira] 2 | jql "project = SCRUM AND sprint in openSprints () ORDER BY updated DESC" 3 | -------------------------------------------------------------------------------- /tests/config-good.toml: -------------------------------------------------------------------------------- 1 | [jira] 2 | jql = "project = SCRUM AND sprint in openSprints () ORDER BY updated DESC" 3 | -------------------------------------------------------------------------------- /tests/test.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | cat < $title" 36 | echo "$cmd" 37 | echo 38 | eval "$cmd" 39 | exit_code=$? 40 | if [ $exit_code -eq $expected_exit_code ]; then 41 | echo "✅ command behaves as expected" 42 | ((pass_count++)) 43 | else 44 | echo "❌ command returned $exit_code, expected $expected_exit_code" 45 | ((fail_count++)) 46 | fi 47 | echo 48 | echo "===============================" 49 | echo 50 | done 51 | 52 | echo "Summary:" 53 | echo "- Passed: $pass_count" 54 | echo "- Failed: $fail_count" 55 | 56 | if [ $fail_count -gt 0 ]; then 57 | exit 1 58 | else 59 | exit 0 60 | fi 61 | --------------------------------------------------------------------------------