├── .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 |
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 |
101 |
102 |
103 |
104 |
105 |
106 |
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 |
--------------------------------------------------------------------------------