├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── PULL_REQUEST_TEMPLATE.md ├── boring-cyborg.yml └── workflows │ ├── build.yml │ ├── codeql.yml │ └── docs.yml ├── .gitignore ├── .goreleaser.yml ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── Makefile ├── README.md ├── cliff.toml ├── cmd └── root │ ├── cmd.go │ ├── fetcher.go │ ├── flags.go │ └── uploader.go ├── go.mod ├── go.sum ├── internal ├── cmd │ └── utils │ │ ├── printer.go │ │ ├── tracker.go │ │ ├── tracker_test.go │ │ ├── utils.go │ │ └── utils_test.go └── pkg │ ├── client │ ├── client.go │ ├── client_test.go │ ├── clockify │ │ ├── clockify.go │ │ └── clockify_test.go │ ├── fetcher.go │ ├── harvest │ │ ├── harvest.go │ │ └── harvest_test.go │ ├── tempo │ │ ├── tempo.go │ │ └── tempo_test.go │ ├── timewarrior │ │ ├── timewarrior.go │ │ └── timewarrior_test.go │ ├── toggl │ │ ├── toggl.go │ │ └── toggl_test.go │ ├── uploader.go │ └── uploader_test.go │ ├── utils │ ├── regex.go │ ├── regex_test.go │ └── time.go │ └── worklog │ ├── entry.go │ ├── entry_test.go │ ├── worklog.go │ └── worklog_test.go ├── main.go └── www ├── docs ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE.md ├── assets │ ├── css │ │ └── minutes.css │ └── img │ │ └── hero.png ├── configuration.md ├── getting-started.md ├── index.md ├── migrations │ ├── tempoit.md │ └── toggl-tempo-worklog-transfer.md ├── sources │ ├── clockify.md │ ├── harvest.md │ ├── tempo.md │ ├── timewarrior.md │ └── toggl.md └── targets │ └── tempo.md ├── mkdocs.yml └── requirements.txt /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "[BUG] " 5 | labels: needs triage 6 | assignees: gabor-boros 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | 12 | A clear and concise description of what the bug is. 13 | 14 | **To Reproduce** 15 | 16 | Steps to reproduce the behavior: 17 | 18 | 1. 19 | 20 | **Expected behavior** 21 | 22 | A clear and concise description of what you expected to happen. 23 | 24 | **Screenshots** 25 | 26 | If applicable, add screenshots to help explain your problem. 27 | 28 | **System information:** 29 | 30 | - OS name: 31 | - OS version: 32 | - Version (output of `minutes --version`): 33 | 34 | **List of flags used:** 35 | 36 | ```shell 37 | # Please remove ALL sensitive data before creating an issue! 38 | ``` 39 | 40 | **Additional context** 41 | 42 | Add any other context about the problem here. 43 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "[REQUEST]" 5 | labels: enhancement 6 | assignees: gabor-boros 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | 12 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 13 | 14 | **Describe the solution you'd like** 15 | 16 | A clear and concise description of what you want to happen. 17 | 18 | **Describe alternatives you've considered** 19 | 20 | A clear and concise description of any alternative solutions or features you've considered. 21 | 22 | **Additional context** 23 | 24 | Add any other context or screenshots about the feature request here. 25 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | 3 | Describe what this pull request changes, and why. 4 | 5 | ## Supporting information 6 | 7 | Link to other information about the change, such as, GitHub issues or documentation. 8 | 9 | ### Dependencies 10 | 11 | * _List of the dependencies for this change, otherwise write "N/A"_ 12 | 13 | ### Screenshots 14 | 15 | Screenshots if applicable, otherwise, write "N/A". 16 | 17 | ## Testing instructions 18 | 19 | Please provide detailed step-by-step instructions for testing this change. 20 | 21 | ## Other information 22 | 23 | Include other information, such as, which sources and targets are affected. 24 | 25 | ## Checklist 26 | 27 | - [ ] Documentation is updated 28 | - [ ] Pull request is rebased onto main 29 | - [ ] Commit history is clean 30 | -------------------------------------------------------------------------------- /.github/boring-cyborg.yml: -------------------------------------------------------------------------------- 1 | ##### Labeler ########################################################################################################## 2 | # Enable "labeler" for your PR that would add labels to PRs based on the paths that are modified in the PR. 3 | labelPRBasedOnFilePath: 4 | documentation: 5 | - www/**/* 6 | - README.md 7 | 8 | clockify: 9 | - internal/pkg/client/clockify/**/* 10 | 11 | harvest: 12 | - internal/pkg/client/harvest/**/* 13 | 14 | tempo: 15 | - internal/pkg/client/tempo/**/* 16 | 17 | timewarrior: 18 | - internal/pkg/client/timewarrior/**/* 19 | 20 | toggl: 21 | - internal/pkg/client/toggl/**/* 22 | 23 | ##### Greetings ######################################################################################################## 24 | firstPRWelcomeComment: > 25 | Thanks for opening this pull request! While we review your pull request, please check out our contributing guidelines. 26 | 27 | firstPRMergeComment: > 28 | Awesome work, congrats on your first merged pull request! :tada: 29 | 30 | firstIssueWelcomeComment: > 31 | Thanks for opening your first issue here! Be sure to follow the issue template and provide as much information as you can. 32 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | "on": 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | test: 13 | strategy: 14 | matrix: 15 | go: 16 | - "1.22.4" 17 | os: 18 | - ubuntu-latest 19 | - windows-latest 20 | - macos-latest 21 | runs-on: "${{ matrix.os }}" 22 | steps: 23 | - uses: actions/checkout@v4 24 | - name: Set up Go 25 | uses: actions/setup-go@v5 26 | with: 27 | go-version: "${{ matrix.go }}" 28 | - name: Prerequisites 29 | run: make prerequisites 30 | - name: Lint 31 | run: make lint 32 | - name: Test 33 | run: make test 34 | - name: Stash test results 35 | uses: actions/upload-artifact@master 36 | with: 37 | name: "${{ matrix.os }}-${{ matrix.go }}" 38 | path: .coverage.out 39 | retention-days: 7 40 | - name: Build 41 | run: make build 42 | 43 | coverage: 44 | name: coverage 45 | runs-on: ubuntu-latest 46 | needs: 47 | - test 48 | steps: 49 | - uses: actions/checkout@v4 50 | - name: Unstash test results 51 | uses: actions/download-artifact@master 52 | with: 53 | name: ubuntu-latest-1.22.4 54 | - name: Upload test results 55 | uses: paambaati/codeclimate-action@v6 56 | env: 57 | CC_TEST_REPORTER_ID: c9d94a2c1e909f32ec045ed9653456f64c0666bfde95012e9b913dbe4b988020 58 | with: 59 | prefix: github.com/${{github.repository}} 60 | coverageLocations: ${{github.workspace}}/.coverage.out:gocov 61 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | name: CodeQL 2 | 3 | 'on': 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | schedule: 11 | - cron: 0 0 * * 1 12 | 13 | jobs: 14 | analyze: 15 | name: Analyze 16 | runs-on: ubuntu-latest 17 | permissions: 18 | actions: read 19 | contents: read 20 | security-events: write 21 | strategy: 22 | fail-fast: false 23 | matrix: 24 | language: 25 | - go 26 | steps: 27 | - name: Checkout repository 28 | uses: actions/checkout@v2 29 | - name: Initialize CodeQL 30 | uses: github/codeql-action/init@v1 31 | with: 32 | languages: '${{ matrix.language }}' 33 | - name: Autobuild 34 | uses: github/codeql-action/autobuild@v1 35 | - name: Perform CodeQL Analysis 36 | uses: github/codeql-action/analyze@v1 37 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: Docs 2 | 3 | 'on': 4 | push: 5 | branches: 6 | - main 7 | paths: 8 | - '**.md' 9 | - 'www/**' 10 | 11 | jobs: 12 | deploy: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v2 16 | - uses: actions/setup-python@v2 17 | with: 18 | python-version: 3.x 19 | - run: pip install -r www/requirements.txt 20 | - run: cd www && mkdocs gh-deploy --force 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Editor 2 | .idea/ 3 | .vscode/ 4 | 5 | # Binaries for programs and plugins 6 | *.exe 7 | *.exe~ 8 | *.dll 9 | *.so 10 | *.dylib 11 | 12 | # Test binary, built with `go test -c` 13 | *.test 14 | 15 | # Output of the go coverage tool, specifically when used with LiteIDE 16 | *.out 17 | 18 | # Coverage reports 19 | .coverage 20 | coverage.html 21 | 22 | # Build directory 23 | bin/ 24 | dist/ 25 | 26 | # Misc 27 | .DS_Store 28 | venv/ 29 | virtualenv/ 30 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | before: 2 | hooks: 3 | - make clean 4 | - make deps 5 | builds: 6 | - env: 7 | - CGO_ENABLED=0 8 | goos: 9 | - linux 10 | - windows 11 | - darwin 12 | dist: bin 13 | archives: 14 | - name_template: >- 15 | {{- .ProjectName }}_ 16 | {{- title .Os }}_ 17 | {{- if eq .Arch "amd64" }}x86_64 18 | {{- else if eq .Arch "386" }}i386 19 | {{- else }}{{ .Arch }}{{ end }} 20 | {{- if .Arm }}v{{ .Arm }}{{ end -}} 21 | checksum: 22 | name_template: "checksums.txt" 23 | signs: 24 | - artifacts: all 25 | snapshot: 26 | name_template: "{{ incpatch .Version }}-next" 27 | milestones: 28 | - close: true 29 | fail_on_error: true 30 | changelog: 31 | disable: true 32 | brews: 33 | - repository: 34 | owner: gabor-boros 35 | name: homebrew-brew 36 | commit_author: 37 | name: "Gabor Boros" 38 | email: gabor.brs@gmail.com 39 | directory: Formula 40 | homepage: "https://github.com/gabor-boros/minutes" 41 | description: "Sync worklogs between time trackers, invoicing, and bookkeeping software" 42 | license: "MIT" 43 | dependencies: 44 | - name: go 45 | type: build 46 | custom_block: | 47 | head "https://github.com/gabor-boros/minutes", branch: "main" 48 | 49 | livecheck do 50 | url "https://github.com/gabor-boros/minutes/releases" 51 | regex(/^v(\d+(?:\.\d+)+)$/i) 52 | end 53 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | 2 | # Contributor Covenant Code of Conduct 3 | 4 | ## Our Pledge 5 | 6 | We as members, contributors, and leaders pledge to make participation in our 7 | community a harassment-free experience for everyone, regardless of age, body 8 | size, visible or invisible disability, ethnicity, sex characteristics, gender 9 | identity and expression, level of experience, education, socio-economic status, 10 | nationality, personal appearance, race, caste, color, religion, or sexual identity 11 | and orientation. 12 | 13 | We pledge to act and interact in ways that contribute to an open, welcoming, 14 | diverse, inclusive, and healthy community. 15 | 16 | ## Our Standards 17 | 18 | Examples of behavior that contributes to a positive environment for our 19 | community include: 20 | 21 | * Demonstrating empathy and kindness toward other people 22 | * Being respectful of differing opinions, viewpoints, and experiences 23 | * Giving and gracefully accepting constructive feedback 24 | * Accepting responsibility and apologizing to those affected by our mistakes, 25 | and learning from the experience 26 | * Focusing on what is best not just for us as individuals, but for the 27 | overall community 28 | 29 | Examples of unacceptable behavior include: 30 | 31 | * The use of sexualized language or imagery, and sexual attention or 32 | advances of any kind 33 | * Trolling, insulting or derogatory comments, and personal or political attacks 34 | * Public or private harassment 35 | * Publishing others' private information, such as a physical or email 36 | address, without their explicit permission 37 | * Other conduct which could reasonably be considered inappropriate in a 38 | professional setting 39 | 40 | ## Enforcement Responsibilities 41 | 42 | Community leaders are responsible for clarifying and enforcing our standards of 43 | acceptable behavior and will take appropriate and fair corrective action in 44 | response to any behavior that they deem inappropriate, threatening, offensive, 45 | or harmful. 46 | 47 | Community leaders have the right and responsibility to remove, edit, or reject 48 | comments, commits, code, wiki edits, issues, and other contributions that are 49 | not aligned to this Code of Conduct, and will communicate reasons for moderation 50 | decisions when appropriate. 51 | 52 | ## Scope 53 | 54 | This Code of Conduct applies within all community spaces, and also applies when 55 | an individual is officially representing the community in public spaces. 56 | Examples of representing our community include using an official e-mail address, 57 | posting via an official social media account, or acting as an appointed 58 | representative at an online or offline event. 59 | 60 | ## Enforcement 61 | 62 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 63 | reported to the community leaders responsible for enforcement at 64 | gabor.brs@gmail.com. 65 | All complaints will be reviewed and investigated promptly and fairly. 66 | 67 | All community leaders are obligated to respect the privacy and security of the 68 | reporter of any incident. 69 | 70 | ## Enforcement Guidelines 71 | 72 | Community leaders will follow these Community Impact Guidelines in determining 73 | the consequences for any action they deem in violation of this Code of Conduct: 74 | 75 | ### 1. Correction 76 | 77 | **Community Impact**: Use of inappropriate language or other behavior deemed 78 | unprofessional or unwelcome in the community. 79 | 80 | **Consequence**: A private, written warning from community leaders, providing 81 | clarity around the nature of the violation and an explanation of why the 82 | behavior was inappropriate. A public apology may be requested. 83 | 84 | ### 2. Warning 85 | 86 | **Community Impact**: A violation through a single incident or series 87 | of actions. 88 | 89 | **Consequence**: A warning with consequences for continued behavior. No 90 | interaction with the people involved, including unsolicited interaction with 91 | those enforcing the Code of Conduct, for a specified period of time. This 92 | includes avoiding interactions in community spaces as well as external channels 93 | like social media. Violating these terms may lead to a temporary or 94 | permanent ban. 95 | 96 | ### 3. Temporary Ban 97 | 98 | **Community Impact**: A serious violation of community standards, including 99 | sustained inappropriate behavior. 100 | 101 | **Consequence**: A temporary ban from any sort of interaction or public 102 | communication with the community for a specified period of time. No public or 103 | private interaction with the people involved, including unsolicited interaction 104 | with those enforcing the Code of Conduct, is allowed during this period. 105 | Violating these terms may lead to a permanent ban. 106 | 107 | ### 4. Permanent Ban 108 | 109 | **Community Impact**: Demonstrating a pattern of violation of community 110 | standards, including sustained inappropriate behavior, harassment of an 111 | individual, or aggression toward or disparagement of classes of individuals. 112 | 113 | **Consequence**: A permanent ban from any sort of public interaction within 114 | the community. 115 | 116 | ## Attribution 117 | 118 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 119 | version 2.1, available at 120 | [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. 121 | 122 | Community Impact Guidelines were inspired by 123 | [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. 124 | 125 | For answers to common questions about this code of conduct, see the FAQ at 126 | [https://www.contributor-covenant.org/faq][FAQ]. Translations are available 127 | at [https://www.contributor-covenant.org/translations][translations]. 128 | 129 | [homepage]: https://www.contributor-covenant.org 130 | [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html 131 | [Mozilla CoC]: https://github.com/mozilla/diversity 132 | [FAQ]: https://www.contributor-covenant.org/faq 133 | [translations]: https://www.contributor-covenant.org/translations 134 | 135 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Contributions are welcome, and they are greatly appreciated! Every little helps, and credit will always be given. You can contribute in many ways. 4 | 5 | ## Types of Contributions 6 | 7 | ### Report Bugs 8 | 9 | Report bugs at . 10 | 11 | If you are reporting a bug, please use the bug report template, and include: 12 | 13 | - your operating system name and version 14 | - any details about your local setup that might be helpful in troubleshooting 15 | - detailed steps to reproduce the bug 16 | 17 | ### Fix Bugs 18 | 19 | Look through the GitHub issues for bugs. Anything tagged with "bug" and "help wanted" is open to whoever wants to implement it. 20 | 21 | ### Implement Features 22 | 23 | Look through the GitHub issues for features. Anything tagged with "enhancement" and "help wanted" is open to whoever wants to implement it. In case you added a new source or target, do not forget to add them to the docs as well. 24 | 25 | ### Write Documentation 26 | 27 | Minutes could always use more documentation, whether as part of the docs, in docstrings, or even on the web in blog posts, articles, and such. 28 | 29 | ### Submit Feedback 30 | 31 | The best way to send feedback is to file an [issue](https://github.com/gabor-boros/minutes/issues). 32 | 33 | If you are proposing a feature: 34 | 35 | - explain in detail how it would work 36 | - keep the scope as narrow as possible, to make it easier to implement 37 | - remember that this is a volunteer-driven project, and that contributions are welcome :) 38 | 39 | ## Get Started! 40 | 41 | Ready to contribute? Here's how to set up `minutes` for local development. 42 | 43 | As step 0 make sure you have Go 1.17+ and Python 3 installed. 44 | 45 | 1. Fork the repository 46 | 2. Clone your fork locally 47 | 48 | ```shell 49 | $ git clone git@github.com:your_name_here/minutes.git 50 | ``` 51 | 52 | 3. Install prerequisites 53 | 54 | ```shell 55 | $ cd minutes 56 | $ make prerequisites 57 | $ make deps 58 | $ python -m virtualenv -p python3 virtualenv 59 | $ pip install -r www/requirements.txt 60 | ``` 61 | 62 | 4. Create a branch for local development 63 | 64 | ```shell 65 | $ git checkout -b github-username/bugfix-or-feature-name 66 | ``` 67 | 68 | 5. When you're done making changes, check that your changes are formatted, passing linters, and tests are succeeding 69 | 70 | ```shell 71 | $ make format 72 | $ make lint 73 | $ make test 74 | ``` 75 | 76 | 6. Update documentation and check the results by running `make docs` 77 | 7. Commit your changes and push your branch to GitHub 78 | 79 | We use [conventional commits](https://www.conventionalcommits.org/en/v1.0.0-beta.2/), and we require every commit to 80 | follow this pattern. 81 | 82 | ```shell 83 | $ git add . 84 | $ git commit -m "action(scope): summary" 85 | $ git push origin github-username/bugfix-or-feature-name 86 | ``` 87 | 88 | 8. Submit a pull request on GitHub 89 | 90 | ## Pull Request Guidelines 91 | 92 | Before you submit a pull request, check that it meets these guidelines: 93 | 94 | 1. The pull request should include tests 95 | 2. Tests should pass for the PR 96 | 3. If the pull request adds new functionality, or changes existing one, the docs should be updated 97 | 98 | ## Releasing 99 | 100 | A reminder for the maintainers on how to release. 101 | 102 | Before doing anything, ensure you have [git-cliff](https://github.com/orhun/git-cliff) installed, and you already 103 | executed `make prerequisites`. 104 | 105 | 1. Make sure every required PR is merged 106 | 2. Make sure every test is passing both on GitHub and locally 107 | 3. Make sure that formatters are not complaining (`make format` returns 0) 108 | 4. Make sure that linters are not complaining (`make lint` returns 0) 109 | 5. Take a note about the next release version, keeping semantic versioning in mind 110 | 6. Update the CHANGELOG.md using `TAG="" make changelog` 111 | 7. Compare the CHANGELOG.md changes and push to master 112 | 8. Cut a new tag for the next release version 113 | 9. Run `GITHUB_TOKEN="" make release` to package the tool and create a GitHub release 114 | 10. Provide a changelog for the release using CHANGELOG.md 115 | 11. Create a new milestone following the `v` pattern -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright © 2021 Gabor Boros 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: help prerequisites deps format lint test bench coverage-report build release changelog docs clean 2 | .DEFAULT_GOAL := build 3 | 4 | BIN_NAME := minutes 5 | 6 | # NOTE: Set in CI/CD as well 7 | COVERAGE_OUT := .coverage.out 8 | COVERAGE_HTML := coverage.html 9 | 10 | help: ## Show available targets 11 | @echo "Available targets:" 12 | @grep -h -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-15s\033[0m %s\n", $$1, $$2}' 13 | 14 | prerequisites: ## Download and install prerequisites 15 | go install github.com/goreleaser/goreleaser@latest 16 | go install github.com/sqs/goreturns@latest 17 | go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest 18 | go install github.com/securego/gosec/v2/cmd/gosec@latest 19 | go install golang.org/x/vuln/cmd/govulncheck@latest 20 | 21 | deps: ## Download dependencies 22 | go mod download 23 | go mod tidy 24 | 25 | format: ## Run formatter on the project 26 | goreturns -b -local -p -w -e -l . 27 | 28 | lint: format ## Run linters on the project 29 | govulncheck ./... 30 | golangci-lint run --timeout 5m -E revive -e '(struct field|type|method|func) [a-zA-Z`]+ should be [a-zA-Z`]+' 31 | gosec -quiet ./... 32 | 33 | test: deps ## Run tests 34 | go test -cover -covermode=atomic -coverprofile .coverage.out ./... 35 | 36 | bench: deps ## Run benchmarks 37 | # ^$ filters out every unit test, so only benchmarks will run 38 | go test -run '^$$' -benchmem -bench . ./... 39 | 40 | coverage-report: ## Generate coverage report from previous test run 41 | go tool cover -html "$(COVERAGE_OUT)" -o "$(COVERAGE_HTML)" 42 | 43 | build: deps ## Build binary 44 | goreleaser build --clean --snapshot --single-target 45 | @find bin -name "$(BIN_NAME)" -exec cp "{}" bin/ \; 46 | 47 | release: ## Release a new version on GitHub 48 | goreleaser release --clean 49 | 50 | changelog: ## Generate changelog 51 | git-cliff > CHANGELOG.md 52 | 53 | docs: ## Serve the documentation site locally 54 | @cd www && mkdocs serve 55 | 56 | clean: ## Clean up project root 57 | rm -rf bin/ "$(COVERAGE_OUT)" "$(COVERAGE_HTML)" 58 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Contributors][contributors-shield]][contributors-url] 2 | [![Forks][forks-shield]][forks-url] 3 | [![Stargazers][stars-shield]][stars-url] 4 | [![Issues][issues-shield]][issues-url] 5 | [![MIT License][license-shield]][license-url] 6 | [![Maintainability][maintainability-shield]][maintainability-url] 7 | [![Coverage][coverage-shield]][coverage-url] 8 | 9 | 10 |
11 |
12 |

Minutes

13 | 14 |

15 | Sync worklogs between multiple time trackers, invoicing, and bookkeeping software. 16 |
17 | Explore the docs 18 |
19 |
20 | Bug report 21 | · 22 | Feature request 23 |

24 |
25 | 26 | 27 | 28 | ## About The Project 29 | 30 | ![minutes](./www/docs/assets/img/hero.png) 31 | 32 | Minutes is a CLI tool for synchronizing work logs between multiple time trackers, invoicing, and bookkeeping software to make entrepreneurs' daily work easier. Every source and destination comes with their specific flags. Before using any flags, check the related documentation. 33 | 34 | Minutes come with absolutely **NO WARRANTY**; before and after synchronizing any logs, please ensure you got the expected result. 35 | 36 | ## Getting Started 37 | 38 | ### Prerequisites 39 | 40 | Based on the nature of the project, prerequisites depending on what tools you are using. In case you are using Clockify as a time tracker and Tempo as your sync target, you should have an account at Clockify and Jira. 41 | 42 | ### Installation 43 | 44 | #### Using `brew` 45 | 46 | ``` shell 47 | $ brew tap gabor-boros/brew 48 | $ brew install minutes 49 | ``` 50 | 51 | #### Manual install 52 | 53 | To install `minutes`, use one of the [release artifacts](https://github.com/gabor-boros/minutes/releases). If you have `go` installed, you can build from source as well. 54 | 55 | #### Configuration 56 | 57 | `minutes` has numerous flags and there will be more when other sources or targets are added. Therefore, `minutes` comes with a config file, that can be placed to the user's home directory or the config directory. 58 | 59 | _To read more about the config file, please refer to the [Documentation](https://gabor-boros.github.io/minutes/getting-started)_ 60 | 61 | ## Usage 62 | 63 | Below you can find more information about how to use `minutes`. 64 | 65 | ```plaintext 66 | Usage: 67 | minutes [flags] 68 | 69 | Flags: 70 | --clockify-api-key string set the API key 71 | --clockify-url string set the base URL (default "https://api.clockify.me") 72 | --clockify-workspace string set the workspace ID 73 | --config string config file (default is $HOME/.minutes.yaml) 74 | --date-format string set start and end date format (in Go style) (default "2006-01-02 15:04:05") 75 | --dry-run fetch entries, but do not sync them 76 | --end string set the end date (defaults to now) 77 | --filter-client string filter for client name after fetching 78 | --filter-project string filter for project name after fetching 79 | --force-billed-duration treat every second spent as billed 80 | --harvest-account int set the Account ID 81 | --harvest-api-key string set the API key 82 | -h, --help help for minutes 83 | --round-to-closest-minute round time to closest minute 84 | -s, --source string set the source of the sync [clockify harvest tempo timewarrior toggl] 85 | --source-user string set the source user ID 86 | --start string set the start date (defaults to 00:00:00) 87 | --table-hide-column strings hide table column [summary project client start end] 88 | --table-sort-by strings sort table by column [task summary project client start end billable unbillable] (default [start,project,task,summary]) 89 | --tags-as-tasks-regex string regex of the task pattern 90 | -t, --target string set the target of the sync [tempo] 91 | --target-user string set the source user ID 92 | --tempo-password string set the login password 93 | --tempo-url string set the base URL 94 | --tempo-username string set the login user ID 95 | --timewarrior-arguments strings set additional arguments 96 | --timewarrior-client-tag-regex string regex of client tag pattern 97 | --timewarrior-command string set the executable name (default "timew") 98 | --timewarrior-project-tag-regex string regex of project tag pattern 99 | --timewarrior-unbillable-tag string set the unbillable tag (default "unbillable") 100 | --toggl-api-key string set the API key 101 | --toggl-workspace int set the workspace ID 102 | --version show command version 103 | ``` 104 | 105 | ### Usage examples 106 | 107 | Depending on the config file, the number of flags can change. 108 | 109 | #### Simplest command 110 | 111 | ```shell 112 | # No arguments, no flags, just running the command 113 | $ minutes 114 | ``` 115 | 116 | #### Set specific date and time 117 | 118 | ```shell 119 | # Set the date and time to fetch entries in the given time frame 120 | $ minutes --start "2021-10-07 00:00:00" --end "2021-10-07 23:59:59" 121 | ``` 122 | 123 | ```shell 124 | # Specify the start and end date format 125 | $ minutes --date-format "2006-01-02" --start "2021-10-07" --end "2021-10-08" 126 | ``` 127 | 128 | #### Use tags for tasks 129 | 130 | ```shell 131 | # Specify how a tag should look like to be considered as a task 132 | $ minutes --tags-as-tasks-regex '[A-Z]{2,7}-\d{1,6}' 133 | ``` 134 | 135 | #### Minute based rounding 136 | 137 | ```shell 138 | # Set the billed and unbilled time separately 139 | # to round to the closest minute (even if it is zero) 140 | $ minutes --round-to-closest-minute 141 | ``` 142 | 143 | ### Sample config file 144 | 145 | ```toml 146 | # Source config 147 | source = "clockify" 148 | source-user = "" 149 | 150 | clockify-url = "https://api.clockify.me" 151 | clockify-api-key = "" 152 | clockify-workspace = "" 153 | 154 | # Target config 155 | target = "tempo" 156 | target-user = "" 157 | 158 | tempo-url = "https://.atlassian.net" 159 | tempo-username = "" 160 | tempo-password = "" 161 | 162 | # General config 163 | tags-as-tasks-regex = '[A-Z]{2,7}-\d{1,6}' 164 | round-to-closest-minute = true 165 | force-billed-duration = true 166 | 167 | table-sort-by = [ 168 | "start", 169 | "project", 170 | "task", 171 | "summary", 172 | ] 173 | 174 | table-hide-column = [ 175 | "end" 176 | ] 177 | 178 | [table-column-truncates] 179 | summary = 40 180 | project = 10 181 | client = 10 182 | 183 | # Column Config 184 | [table-column-config.summary] 185 | widthmax = 40 186 | ``` 187 | 188 | ## Supported tools 189 | 190 | | Tool | Use as source | Use as target | 191 | | ----------- | ------------- | ------------- | 192 | | Clockify | **yes** | upon request | 193 | | Everhour | upon request | upon request | 194 | | FreshBooks | upon request | **planned** | 195 | | Harvest | **yes** | upon request | 196 | | QuickBooks | upon request | upon request | 197 | | Tempo | **yes** | **yes** | 198 | | Time Doctor | upon request | upon request | 199 | | TimeCamp | upon request | upon request | 200 | | Timewarrior | **yes** | upon request | 201 | | Toggl Track | **yes** | upon request | 202 | | Zoho Books | upon request | **planned** | 203 | 204 | See the [open issues](https://github.com/gabor-boros/minutes/issues) for a full list of proposed features, tools and known issues. 205 | 206 | ## Unsupported features 207 | 208 | The following list of features are not supported at the moment: 209 | 210 | - Cost rate sync 211 | - Hourly rate sync 212 | - Estimate sync 213 | - Multiple source and target user support 214 | 215 | ## Contributing 216 | 217 | Contributions are what make the open source community such an amazing place to learn, inspire, and create. Any contributions you make are **greatly appreciated**. 218 | 219 | If you have a suggestion that would make this tool better, please fork the repo and create a pull request. You can also simply open an issue. 220 | Don't forget to give the project a star! 221 | 222 | 1. Fork the Project 223 | 2. Create your Feature Branch (`git checkout -b github-username/amazing-feature`) 224 | 3. Commit your Changes (`git commit -m 'feat(new tool): add my favorite tool as a source`) 225 | 4. Push to the Branch (`git push origin github-username/amazing-feature`) 226 | 5. Open a Pull Request 227 | 228 | 229 | 230 | [contributors-shield]: https://img.shields.io/github/contributors/gabor-boros/minutes.svg 231 | [contributors-url]: https://github.com/gabor-boros/minutes/graphs/contributors 232 | [forks-shield]: https://img.shields.io/github/forks/gabor-boros/minutes.svg 233 | [forks-url]: https://github.com/gabor-boros/minutes/network/members 234 | [stars-shield]: https://img.shields.io/github/stars/gabor-boros/minutes.svg 235 | [stars-url]: https://github.com/gabor-boros/minutes/stargazers 236 | [issues-shield]: https://img.shields.io/github/issues/gabor-boros/minutes.svg 237 | [issues-url]: https://github.com/gabor-boros/minutes/issues 238 | [license-shield]: https://img.shields.io/github/license/gabor-boros/minutes.svg 239 | [license-url]: https://github.com/gabor-boros/minutes/blob/main/LICENSE 240 | [maintainability-shield]: https://api.codeclimate.com/v1/badges/316725f57830f48733e8/maintainability 241 | [maintainability-url]: https://codeclimate.com/github/gabor-boros/minutes/maintainability 242 | [coverage-shield]: https://api.codeclimate.com/v1/badges/316725f57830f48733e8/test_coverage 243 | [coverage-url]: https://codeclimate.com/github/gabor-boros/minutes/test_coverage 244 | -------------------------------------------------------------------------------- /cliff.toml: -------------------------------------------------------------------------------- 1 | # configuration file for git-cliff (0.1.0) 2 | 3 | [changelog] 4 | header = """ 5 | # Changelog 6 | 7 | All notable changes to this project will be documented in this file.\n 8 | """ 9 | # template for the changelog body 10 | # https://tera.netlify.app/docs/#introduction 11 | body = """ 12 | {% if version %}\ 13 | ## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }} 14 | {% else %}\ 15 | ## [unreleased] 16 | {% endif %}\ 17 | {% for group, commits in commits | group_by(attribute="group") %} 18 | **{{ group | upper_first }}** 19 | {% for commit in commits %} 20 | - {{ commit.message | upper_first }} ([{{ commit.id | truncate(length=7, end="") }}](https://github.com/gabor-boros/minutes/commit/{{ commit.id }}))\ 21 | {% endfor %} 22 | {% endfor %}\n 23 | """ 24 | 25 | trim = true 26 | footer = "" 27 | 28 | [git] 29 | # allow only conventional commits 30 | # https://www.conventionalcommits.org 31 | conventional_commits = true 32 | # regex for parsing and grouping commits 33 | commit_parsers = [ 34 | { message = "^feat", group = "Features"}, 35 | { message = "^fix", group = "Bug Fixes"}, 36 | { message = "^doc", group = "Documentation"}, 37 | { message = "^perf", group = "Performance"}, 38 | { message = "^refactor", group = "Refactor"}, 39 | { message = "^style", group = "Styling"}, 40 | { message = "^test", group = "Testing"}, 41 | { message = "^chore\\(release\\): prepare for", skip = true}, 42 | { message = "^chore\\(changelog\\):", skip = true}, 43 | { message = "^chore", group = "Miscellaneous Tasks"}, 44 | { body = ".*security", group = "Security"}, 45 | ] 46 | # filter out the commits that are not matched by commit parsers 47 | filter_commits = false 48 | # glob pattern for matching git tags 49 | tag_pattern = "v[0-9]*" 50 | # regex for skipping tags 51 | skip_tags = "v0.1.0-beta.1" 52 | -------------------------------------------------------------------------------- /cmd/root/cmd.go: -------------------------------------------------------------------------------- 1 | package root 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "regexp" 8 | "strings" 9 | "time" 10 | 11 | "github.com/gabor-boros/minutes/internal/cmd/utils" 12 | 13 | "github.com/jedib0t/go-pretty/v6/progress" 14 | "github.com/jedib0t/go-pretty/v6/table" 15 | 16 | "github.com/gabor-boros/minutes/internal/pkg/client" 17 | "github.com/gabor-boros/minutes/internal/pkg/worklog" 18 | "github.com/spf13/cobra" 19 | "github.com/spf13/viper" 20 | ) 21 | 22 | const ( 23 | program string = "minutes" 24 | defaultDateFormat string = "2006-01-02 15:04:05" 25 | ) 26 | 27 | var ( 28 | configFile string 29 | envPrefix string 30 | 31 | version string 32 | commit string 33 | date string 34 | 35 | rootCmd = &cobra.Command{ 36 | Use: program, 37 | Short: "Sync worklogs between multiple time trackers, invoicing, and bookkeeping software.", 38 | Long: ` 39 | Minutes is a CLI tool for synchronizing work logs between multiple time 40 | trackers, invoicing, and bookkeeping software to make entrepreneurs' 41 | daily work easier. 42 | 43 | Every source and destination comes with their specific flags. Before using any 44 | flags, check the related documentation. 45 | 46 | Minutes comes with absolutely NO WARRANTY; for more information, visit the 47 | project's home page. 48 | 49 | Project home page: https://gabor-boros.github.io/minutes 50 | Report bugs at: https://github.com/gabor-boros/minutes/issues 51 | Report security issues to: gabor.brs@gmail.com`, 52 | Run: runRootCmd, 53 | } 54 | ) 55 | 56 | func init() { 57 | envPrefix = strings.ToUpper(program) 58 | 59 | cobra.OnInitialize(initConfig) 60 | 61 | initCommonFlags() 62 | initClockifyFlags() 63 | initHarvestFlags() 64 | initTempoFlags() 65 | initTimewarriorFlags() 66 | initTogglFlags() 67 | } 68 | 69 | func initConfig() { 70 | if configFile != "" { 71 | viper.SetConfigName(configFile) 72 | } else { 73 | homeDir, err := os.UserHomeDir() 74 | cobra.CheckErr(err) 75 | 76 | configDir, err := os.UserConfigDir() 77 | cobra.CheckErr(err) 78 | 79 | viper.AddConfigPath(homeDir) 80 | viper.AddConfigPath(configDir) 81 | viper.SetConfigName("." + program) 82 | viper.SetConfigType("toml") 83 | } 84 | 85 | viper.SetEnvPrefix(envPrefix) 86 | viper.AutomaticEnv() 87 | 88 | if err := viper.ReadInConfig(); err != nil { 89 | if _, ok := err.(viper.ConfigFileNotFoundError); !ok { 90 | cobra.CheckErr(err) 91 | } 92 | } else { 93 | fmt.Println("Using config file:", viper.ConfigFileUsed(), configFile) 94 | } 95 | 96 | // Bind flags to config value 97 | cobra.CheckErr(viper.BindPFlags(rootCmd.Flags())) 98 | } 99 | 100 | func runRootCmd(_ *cobra.Command, _ []string) { 101 | var err error 102 | 103 | if viper.GetBool("version") { 104 | if version == "" || len(commit) < 7 || date == "" { 105 | fmt.Println("dirty build") 106 | } else { 107 | fmt.Printf("%s version %s, commit %s (%s)\n", program, version, commit[:7], date) 108 | } 109 | os.Exit(0) 110 | } 111 | 112 | validateFlags() 113 | 114 | dateFormat := viper.GetString("date-format") 115 | 116 | start, err := utils.GetTime(viper.GetString("start"), dateFormat) 117 | cobra.CheckErr(err) 118 | 119 | rawEnd := viper.GetString("end") 120 | end, err := utils.GetTime(rawEnd, dateFormat) 121 | cobra.CheckErr(err) 122 | 123 | // No end date was set, hence we are setting the end date to next day midnight 124 | if rawEnd == "" { 125 | end = end.Add(time.Hour * 24) 126 | } 127 | 128 | fetcher, err := getFetcher() 129 | cobra.CheckErr(err) 130 | 131 | uploader, err := getUploader() 132 | cobra.CheckErr(err) 133 | 134 | tagsAsTasksRegex, err := regexp.Compile(viper.GetString("tags-as-tasks-regex")) 135 | cobra.CheckErr(err) 136 | 137 | entries, err := fetcher.FetchEntries(context.Background(), &client.FetchOpts{ 138 | End: end, 139 | Start: start, 140 | User: viper.GetString("source-user"), 141 | TagsAsTasksRegex: tagsAsTasksRegex, 142 | }) 143 | cobra.CheckErr(err) 144 | 145 | // It is safe to use MustCompile when compiling regex as we already 146 | // validated its correctness 147 | wl := worklog.NewWorklog(entries, &worklog.FilterOpts{ 148 | Client: regexp.MustCompile(viper.GetString("filter-client")), 149 | Project: regexp.MustCompile(viper.GetString("filter-project")), 150 | }) 151 | 152 | completeEntries := wl.CompleteEntries() 153 | incompleteEntries := wl.IncompleteEntries() 154 | 155 | columnTruncates := map[string]int{} 156 | err = viper.UnmarshalKey("table-column-truncates", &columnTruncates) 157 | cobra.CheckErr(err) 158 | 159 | tablePrinter := utils.NewTablePrinter(&utils.TablePrinterOpts{ 160 | BasePrinterOpts: utils.BasePrinterOpts{ 161 | Output: os.Stdout, 162 | AutoIndex: true, 163 | Title: fmt.Sprintf("Worklog entries (%s - %s)", start.Local().String(), end.Local().String()), 164 | SortBy: viper.GetStringSlice("table-sort-by"), 165 | HiddenColumns: viper.GetStringSlice("table-hide-column"), 166 | }, 167 | Style: table.StyleLight, 168 | ColumnConfig: utils.ParseColumnConfigs( 169 | "table-column-config.%s", 170 | viper.GetStringSlice("table-hide-column"), 171 | ), 172 | ColumnTruncates: columnTruncates, 173 | }) 174 | 175 | err = tablePrinter.Print(completeEntries, incompleteEntries) 176 | cobra.CheckErr(err) 177 | 178 | if strings.ToLower(utils.Prompt("Continue? [y/n]: ")) != "y" { 179 | fmt.Println("User interruption. Aborting.") 180 | os.Exit(0) 181 | } 182 | 183 | // In worst case, the maximum number of errors will match the number of entries 184 | uploadErrChan := make(chan error, len(completeEntries)) 185 | 186 | fmt.Printf("\nUploading worklog entries:\n\n") 187 | if !viper.GetBool("dry-run") { 188 | progressUpdateFrequency := progress.DefaultUpdateFrequency 189 | progressWriter := utils.NewProgressWriter(progressUpdateFrequency) 190 | 191 | // Intentionally called as a goroutine 192 | go progressWriter.Render() 193 | 194 | uploader.UploadEntries(context.Background(), completeEntries, uploadErrChan, &client.UploadOpts{ 195 | RoundToClosestMinute: viper.GetBool("round-to-closest-minute"), 196 | TreatDurationAsBilled: viper.GetBool("force-billed-duration"), 197 | CreateMissingResources: false, 198 | User: viper.GetString("target-user"), 199 | ProgressWriter: progressWriter, 200 | }) 201 | 202 | // Wait for at least one tracker to appear and while the rendering is in progress, 203 | // wait for the remaining updates to render. 204 | time.Sleep(time.Second) 205 | for progressWriter.IsRenderInProgress() { 206 | time.Sleep(progressUpdateFrequency) 207 | } 208 | } 209 | 210 | var uploadErrors []error 211 | for i := 0; i < len(completeEntries); i++ { 212 | if err := <-uploadErrChan; err != nil { 213 | uploadErrors = append(uploadErrors, err) 214 | } 215 | } 216 | 217 | if errCount := len(uploadErrors); errCount != 0 { 218 | fmt.Printf("\nFailed to upload %d worklog entries!\n\n", errCount) 219 | for _, err := range uploadErrors { 220 | fmt.Println(err) 221 | } 222 | os.Exit(1) 223 | } 224 | 225 | fmt.Printf("\nSuccessfully uploaded %d worklog entries!\n", len(completeEntries)) 226 | } 227 | 228 | func Execute(buildVersion string, buildCommit string, buildDate string) { 229 | version = buildVersion 230 | commit = buildCommit 231 | date = buildDate 232 | 233 | cobra.CheckErr(rootCmd.Execute()) 234 | } 235 | -------------------------------------------------------------------------------- /cmd/root/fetcher.go: -------------------------------------------------------------------------------- 1 | package root 2 | 3 | import ( 4 | "errors" 5 | "os/exec" 6 | 7 | "github.com/gabor-boros/minutes/internal/pkg/client" 8 | "github.com/gabor-boros/minutes/internal/pkg/client/clockify" 9 | "github.com/gabor-boros/minutes/internal/pkg/client/harvest" 10 | "github.com/gabor-boros/minutes/internal/pkg/client/tempo" 11 | "github.com/gabor-boros/minutes/internal/pkg/client/timewarrior" 12 | "github.com/gabor-boros/minutes/internal/pkg/client/toggl" 13 | "github.com/spf13/viper" 14 | ) 15 | 16 | var ( 17 | ErrNoSourceImplementation = errors.New("no source implementation found") 18 | ) 19 | 20 | func getClockifyFetcher() (client.Fetcher, error) { 21 | return clockify.NewFetcher(&clockify.ClientOpts{ 22 | BaseClientOpts: client.BaseClientOpts{ 23 | Timeout: client.DefaultRequestTimeout, 24 | }, 25 | TokenAuth: client.TokenAuth{ 26 | Header: "X-Api-Key", 27 | Token: viper.GetString("clockify-api-key"), 28 | }, 29 | BaseURL: viper.GetString("clockify-url"), 30 | Workspace: viper.GetString("clockify-workspace"), 31 | }) 32 | } 33 | 34 | func getHarvestFetcher() (client.Fetcher, error) { 35 | return harvest.NewFetcher(&harvest.ClientOpts{ 36 | BaseClientOpts: client.BaseClientOpts{ 37 | Timeout: client.DefaultRequestTimeout, 38 | }, 39 | TokenAuth: client.TokenAuth{ 40 | TokenName: "Bearer", 41 | Token: viper.GetString("harvest-api-key"), 42 | }, 43 | BaseURL: "https://api.harvestapp.com", 44 | Account: viper.GetInt("harvest-account"), 45 | }) 46 | } 47 | 48 | func getTempoFetcher() (client.Fetcher, error) { 49 | return tempo.NewFetcher(&tempo.ClientOpts{ 50 | BaseClientOpts: client.BaseClientOpts{ 51 | Timeout: client.DefaultRequestTimeout, 52 | }, 53 | BasicAuth: client.BasicAuth{ 54 | Username: viper.GetString("tempo-username"), 55 | Password: viper.GetString("tempo-password"), 56 | }, 57 | BaseURL: viper.GetString("tempo-url"), 58 | }) 59 | } 60 | 61 | func getTimeWarriorFetcher() (client.Fetcher, error) { 62 | return timewarrior.NewFetcher(&timewarrior.ClientOpts{ 63 | BaseClientOpts: client.BaseClientOpts{ 64 | Timeout: client.DefaultRequestTimeout, 65 | }, 66 | CLIClient: client.CLIClient{ 67 | Command: viper.GetString("timewarrior-command"), 68 | CommandArguments: viper.GetStringSlice("timewarrior-arguments"), 69 | CommandCtxExecutor: exec.CommandContext, 70 | }, 71 | UnbillableTag: viper.GetString("timewarrior-unbillable-tag"), 72 | ClientTagRegex: viper.GetString("timewarrior-client-tag-regex"), 73 | ProjectTagRegex: viper.GetString("timewarrior-project-tag-regex"), 74 | }) 75 | } 76 | 77 | func getTogglFetcher() (client.Fetcher, error) { 78 | return toggl.NewFetcher(&toggl.ClientOpts{ 79 | BaseClientOpts: client.BaseClientOpts{ 80 | Timeout: client.DefaultRequestTimeout, 81 | }, 82 | BasicAuth: client.BasicAuth{ 83 | Username: viper.GetString("toggl-api-key"), 84 | Password: "api_token", 85 | }, 86 | BaseURL: "https://api.track.toggl.com", 87 | Workspace: viper.GetInt("toggl-workspace"), 88 | }) 89 | } 90 | 91 | func getFetcher() (client.Fetcher, error) { 92 | 93 | var fetcher client.Fetcher 94 | var err error 95 | 96 | switch viper.GetString("source") { 97 | case "clockify": 98 | fetcher, err = getClockifyFetcher() 99 | case "harvest": 100 | fetcher, err = getHarvestFetcher() 101 | case "tempo": 102 | fetcher, err = getTempoFetcher() 103 | case "timewarrior": 104 | fetcher, err = getTimeWarriorFetcher() 105 | case "toggl": 106 | fetcher, err = getTogglFetcher() 107 | default: 108 | fetcher, err = nil, ErrNoSourceImplementation 109 | } 110 | 111 | return fetcher, err 112 | } 113 | -------------------------------------------------------------------------------- /cmd/root/flags.go: -------------------------------------------------------------------------------- 1 | package root 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | "strings" 7 | 8 | "github.com/gabor-boros/minutes/internal/cmd/utils" 9 | "github.com/spf13/cobra" 10 | "github.com/spf13/viper" 11 | ) 12 | 13 | var ( 14 | sources = []string{"clockify", "harvest", "tempo", "timewarrior", "toggl"} 15 | targets = []string{"tempo"} 16 | ) 17 | 18 | func initCommonFlags() { 19 | rootCmd.PersistentFlags().StringVar(&configFile, "config", "", fmt.Sprintf("config file (default is $HOME/.%s.yaml)", program)) 20 | 21 | rootCmd.Flags().StringP("start", "", "", "set the start date (defaults to 00:00:00)") 22 | rootCmd.Flags().StringP("end", "", "", "set the end date (defaults to now)") 23 | rootCmd.Flags().StringP("date-format", "", defaultDateFormat, "set start and end date format (in Go style)") 24 | 25 | rootCmd.Flags().StringP("source-user", "", "", "set the source user ID") 26 | rootCmd.Flags().StringP("source", "s", "", fmt.Sprintf("set the source of the sync %v", sources)) 27 | 28 | rootCmd.Flags().StringP("target-user", "", "", "set the source user ID") 29 | rootCmd.Flags().StringP("target", "t", "", fmt.Sprintf("set the target of the sync %v", targets)) 30 | 31 | rootCmd.Flags().StringSliceP("table-sort-by", "", []string{utils.ColumnStart, utils.ColumnProject, utils.ColumnTask, utils.ColumnSummary}, fmt.Sprintf("sort table by column %v", utils.Columns)) 32 | rootCmd.Flags().StringSliceP("table-hide-column", "", []string{}, fmt.Sprintf("hide table column %v", utils.HideableColumns)) 33 | 34 | rootCmd.Flags().StringP("tags-as-tasks-regex", "", "", "regex of the task pattern") 35 | 36 | rootCmd.Flags().BoolP("round-to-closest-minute", "", false, "round time to closest minute") 37 | rootCmd.Flags().BoolP("force-billed-duration", "", false, "treat every second spent as billed") 38 | 39 | rootCmd.Flags().StringP("filter-client", "", "", "filter for client name after fetching") 40 | rootCmd.Flags().StringP("filter-project", "", "", "filter for project name after fetching") 41 | 42 | rootCmd.Flags().BoolP("dry-run", "", false, "fetch entries, but do not sync them") 43 | rootCmd.Flags().BoolP("version", "", false, "show command version") 44 | } 45 | 46 | func initClockifyFlags() { 47 | rootCmd.Flags().StringP("clockify-url", "", "https://api.clockify.me", "set the base URL") 48 | rootCmd.Flags().StringP("clockify-api-key", "", "", "set the API key") 49 | rootCmd.Flags().StringP("clockify-workspace", "", "", "set the workspace ID") 50 | } 51 | 52 | func initHarvestFlags() { 53 | rootCmd.Flags().StringP("harvest-api-key", "", "", "set the API key") 54 | rootCmd.Flags().IntP("harvest-account", "", 0, "set the Account ID") 55 | } 56 | 57 | func initTempoFlags() { 58 | rootCmd.Flags().StringP("tempo-url", "", "", "set the base URL") 59 | rootCmd.Flags().StringP("tempo-username", "", "", "set the login user ID") 60 | rootCmd.Flags().StringP("tempo-password", "", "", "set the login password") 61 | } 62 | 63 | func initTimewarriorFlags() { 64 | rootCmd.Flags().StringP("timewarrior-command", "", "timew", "set the executable name") 65 | rootCmd.Flags().StringSliceP("timewarrior-arguments", "", []string{}, "set additional arguments") 66 | 67 | rootCmd.Flags().StringP("timewarrior-unbillable-tag", "", "unbillable", "set the unbillable tag") 68 | rootCmd.Flags().StringP("timewarrior-client-tag-regex", "", "", "regex of client tag pattern") 69 | rootCmd.Flags().StringP("timewarrior-project-tag-regex", "", "", "regex of project tag pattern") 70 | } 71 | 72 | func initTogglFlags() { 73 | rootCmd.Flags().StringP("toggl-api-key", "", "", "set the API key") 74 | rootCmd.Flags().IntP("toggl-workspace", "", 0, "set the workspace ID") 75 | } 76 | 77 | func validateFlags() { 78 | var err error 79 | source := viper.GetString("source") 80 | target := viper.GetString("target") 81 | 82 | if source == "" { 83 | cobra.CheckErr("sync source must be set") 84 | } 85 | 86 | if target == "" { 87 | cobra.CheckErr("sync target must be set") 88 | } 89 | 90 | if source == target { 91 | cobra.CheckErr("sync source cannot match the target") 92 | } 93 | 94 | if !utils.IsSliceContains(source, sources) { 95 | cobra.CheckErr(fmt.Sprintf("\"%s\" is not part of the supported sources %v\n", source, sources)) 96 | } 97 | 98 | if !utils.IsSliceContains(target, targets) { 99 | cobra.CheckErr(fmt.Sprintf("\"%s\" is not part of the supported targets %v\n", target, targets)) 100 | } 101 | 102 | tagsAsTasksRegex := viper.GetString("tags-as-tasks-regex") 103 | _, err = regexp.Compile(tagsAsTasksRegex) 104 | cobra.CheckErr(err) 105 | 106 | for _, sortBy := range viper.GetStringSlice("table-sort-by") { 107 | column := sortBy 108 | 109 | if strings.HasPrefix(column, "-") { 110 | column = sortBy[1:] 111 | } 112 | 113 | if !utils.IsSliceContains(column, utils.Columns) { 114 | cobra.CheckErr(fmt.Sprintf("\"%s\" is not part of the sortable columns %v\n", column, utils.Columns)) 115 | } 116 | } 117 | 118 | for _, column := range viper.GetStringSlice("table-hide-column") { 119 | if !utils.IsSliceContains(column, utils.HideableColumns) { 120 | cobra.CheckErr(fmt.Sprintf("\"%s\" is not part of the hideable columns %v\n", column, utils.HideableColumns)) 121 | } 122 | } 123 | 124 | _, err = regexp.Compile(viper.GetString("filter-client")) 125 | cobra.CheckErr(err) 126 | 127 | _, err = regexp.Compile(viper.GetString("filter-project")) 128 | cobra.CheckErr(err) 129 | 130 | switch source { 131 | case "timewarrior": 132 | if viper.GetString("timewarrior-command") == "" { 133 | cobra.CheckErr("timewarrior command must be set") 134 | } 135 | 136 | if viper.GetString("timewarrior-unbillable-tag") == "" { 137 | cobra.CheckErr("timewarrior unbillable tag must be set") 138 | } 139 | 140 | if viper.GetString("timewarrior-client-tag-regex") == "" { 141 | cobra.CheckErr("timewarrior client tag regex must be set") 142 | } 143 | 144 | if viper.GetString("timewarrior-project-tag-regex") == "" { 145 | cobra.CheckErr("timewarrior project tag regex must be set") 146 | } 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /cmd/root/uploader.go: -------------------------------------------------------------------------------- 1 | package root 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/gabor-boros/minutes/internal/pkg/client" 7 | "github.com/gabor-boros/minutes/internal/pkg/client/tempo" 8 | "github.com/spf13/viper" 9 | ) 10 | 11 | var ( 12 | ErrNoTargetImplementation = errors.New("no target implementation found") 13 | ) 14 | 15 | func getUploader() (client.Uploader, error) { 16 | switch viper.GetString("target") { 17 | case "tempo": 18 | return tempo.NewUploader(&tempo.ClientOpts{ 19 | BaseClientOpts: client.BaseClientOpts{ 20 | Timeout: client.DefaultRequestTimeout, 21 | }, 22 | BasicAuth: client.BasicAuth{ 23 | Username: viper.GetString("tempo-username"), 24 | Password: viper.GetString("tempo-password"), 25 | }, 26 | BaseURL: viper.GetString("tempo-url"), 27 | }) 28 | default: 29 | return nil, ErrNoTargetImplementation 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/gabor-boros/minutes 2 | 3 | go 1.22.4 4 | 5 | require ( 6 | github.com/jedib0t/go-pretty/v6 v6.5.9 7 | github.com/spf13/cobra v1.8.0 8 | github.com/spf13/viper v1.19.0 9 | github.com/stretchr/testify v1.9.0 10 | ) 11 | 12 | require ( 13 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 14 | github.com/fsnotify/fsnotify v1.7.0 // indirect 15 | github.com/hashicorp/hcl v1.0.0 // indirect 16 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 17 | github.com/magiconair/properties v1.8.7 // indirect 18 | github.com/mattn/go-runewidth v0.0.15 // indirect 19 | github.com/mitchellh/mapstructure v1.5.0 // indirect 20 | github.com/pelletier/go-toml/v2 v2.2.2 // indirect 21 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 22 | github.com/rivo/uniseg v0.4.7 // indirect 23 | github.com/sagikazarmark/locafero v0.6.0 // indirect 24 | github.com/sagikazarmark/slog-shim v0.1.0 // indirect 25 | github.com/sourcegraph/conc v0.3.0 // indirect 26 | github.com/spf13/afero v1.11.0 // indirect 27 | github.com/spf13/cast v1.6.0 // indirect 28 | github.com/spf13/pflag v1.0.5 // indirect 29 | github.com/subosito/gotenv v1.6.0 // indirect 30 | go.uber.org/multierr v1.11.0 // indirect 31 | golang.org/x/exp v0.0.0-20240604190554-fc45aab8b7f8 // indirect 32 | golang.org/x/sys v0.21.0 // indirect 33 | golang.org/x/term v0.21.0 // indirect 34 | golang.org/x/text v0.16.0 // indirect 35 | gopkg.in/ini.v1 v1.67.0 // indirect 36 | gopkg.in/yaml.v3 v3.0.1 // indirect 37 | ) 38 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 2 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 4 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 5 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 6 | github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= 7 | github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= 8 | github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= 9 | github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= 10 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 11 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 12 | github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= 13 | github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= 14 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 15 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 16 | github.com/jedib0t/go-pretty/v6 v6.5.9 h1:ACteMBRrrmm1gMsXe9PSTOClQ63IXDUt03H5U+UV8OU= 17 | github.com/jedib0t/go-pretty/v6 v6.5.9/go.mod h1:zbn98qrYlh95FIhwwsbIip0LYpwSG8SUOScs+v9/t0E= 18 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 19 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 20 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 21 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 22 | github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= 23 | github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= 24 | github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= 25 | github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 26 | github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= 27 | github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= 28 | github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= 29 | github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= 30 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 31 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= 32 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 33 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 34 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 35 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 36 | github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= 37 | github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= 38 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 39 | github.com/sagikazarmark/locafero v0.6.0 h1:ON7AQg37yzcRPU69mt7gwhFEBwxI6P9T4Qu3N51bwOk= 40 | github.com/sagikazarmark/locafero v0.6.0/go.mod h1:77OmuIc6VTraTXKXIs/uvUxKGUXjE1GbemJYHqdNjX0= 41 | github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= 42 | github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= 43 | github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= 44 | github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= 45 | github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= 46 | github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= 47 | github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= 48 | github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= 49 | github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= 50 | github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= 51 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 52 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 53 | github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI= 54 | github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg= 55 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 56 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 57 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 58 | github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= 59 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 60 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 61 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 62 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 63 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 64 | github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= 65 | github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= 66 | go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= 67 | go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= 68 | golang.org/x/exp v0.0.0-20240604190554-fc45aab8b7f8 h1:LoYXNGAShUG3m/ehNk4iFctuhGX/+R1ZpfJ4/ia80JM= 69 | golang.org/x/exp v0.0.0-20240604190554-fc45aab8b7f8/go.mod h1:jj3sYF3dwk5D+ghuXyeI3r5MFf+NT2An6/9dOA95KSI= 70 | golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= 71 | golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 72 | golang.org/x/term v0.21.0 h1:WVXCp+/EBEHOj53Rvu+7KiT/iElMrO8ACK16SMZ3jaA= 73 | golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0= 74 | golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= 75 | golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= 76 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 77 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= 78 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 79 | gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= 80 | gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= 81 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 82 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 83 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 84 | -------------------------------------------------------------------------------- /internal/cmd/utils/printer.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "strings" 7 | "time" 8 | 9 | "github.com/spf13/cobra" 10 | 11 | "github.com/gabor-boros/minutes/internal/pkg/worklog" 12 | "github.com/jedib0t/go-pretty/v6/table" 13 | "github.com/jedib0t/go-pretty/v6/text" 14 | "github.com/spf13/viper" 15 | ) 16 | 17 | const ( 18 | rowDateFormat string = "2006-01-02 15:04:05" 19 | ColumnTask string = "task" 20 | ColumnSummary string = "summary" 21 | ColumnProject string = "project" 22 | ColumnClient string = "client" 23 | ColumnStart string = "start" 24 | ColumnEnd string = "end" 25 | ColumnBillable string = "billable" 26 | ColumnUnbillable string = "unbillable" 27 | ) 28 | 29 | // Columns lists all available columns that can be printed. 30 | var Columns = []string{ 31 | ColumnTask, 32 | ColumnSummary, 33 | ColumnProject, 34 | ColumnClient, 35 | ColumnStart, 36 | ColumnEnd, 37 | ColumnBillable, 38 | ColumnUnbillable, 39 | } 40 | 41 | // HideableColumns lists all columns that can be hidden when printing. 42 | var HideableColumns = []string{ 43 | ColumnSummary, 44 | ColumnProject, 45 | ColumnClient, 46 | ColumnStart, 47 | ColumnEnd, 48 | } 49 | 50 | // TableColumnConfig represents the configuration of a column. 51 | // The configuration is built up from two parts, `Config` which stands for the 52 | // table column config and `TruncateAt` which defines the max length a column 53 | // text; longer texts will be truncated. 54 | type TableColumnConfig struct { 55 | Config table.ColumnConfig 56 | TruncateAt int 57 | } 58 | 59 | // Printer represents a printer that can write worklog entries. 60 | type Printer interface { 61 | // Print prints out the list of complete and incomplete entries. 62 | // The output location must be set through `BasePrinterOpts`. 63 | Print(completeEntries worklog.Entries, incompleteEntries worklog.Entries) error 64 | } 65 | 66 | // BasePrinterOpts represents the configuration for common printer options. 67 | type BasePrinterOpts struct { 68 | // Output is the location where `Print` prints. 69 | Output io.Writer 70 | // AutoIndex adds row number as the first column. 71 | AutoIndex bool 72 | // Title sets the printed data's title. 73 | // In case of tables, the title is the full-width first row. 74 | Title string 75 | // SortBy sets the list of columns that are used for sorting. 76 | // If a column name starts with `-` (hyphen), the direction is descending; 77 | // otherwise, the direction is treated as ascending. 78 | SortBy []string 79 | // HiddenColumns lists the columns that will be hidden during printing. 80 | HiddenColumns []string 81 | } 82 | 83 | // TablePrinterOpts represents the configuration for a table base printer. 84 | // Table based printer sends the output to os.Stdout and draws an ascii-based 85 | // table. 86 | type TablePrinterOpts struct { 87 | BasePrinterOpts 88 | Style table.Style 89 | ColumnConfig []table.ColumnConfig 90 | ColumnTruncates map[string]int 91 | } 92 | 93 | type tablePrinter struct { 94 | writer table.Writer 95 | truncateMap map[string]int 96 | } 97 | 98 | func (p *tablePrinter) convertEntryToRow(entry *worklog.Entry) table.Row { 99 | entryStart := entry.Start.Local() 100 | timeSpent := entry.BillableDuration + entry.UnbillableDuration 101 | 102 | return table.Row{ 103 | Truncate(entry.Task.Name, p.truncateMap[ColumnTask]), 104 | Truncate(entry.Summary, p.truncateMap[ColumnSummary]), 105 | Truncate(entry.Project.Name, p.truncateMap[ColumnProject]), 106 | Truncate(entry.Client.Name, p.truncateMap[ColumnClient]), 107 | entryStart.Format(rowDateFormat), 108 | entryStart.Add(timeSpent).Format(rowDateFormat), 109 | entry.BillableDuration, 110 | entry.UnbillableDuration, 111 | } 112 | } 113 | 114 | func (p *tablePrinter) generateRows(entries worklog.Entries, billable *time.Duration, unbillable *time.Duration) { 115 | for i := range entries { 116 | entry := entries[i] 117 | *billable += entry.BillableDuration 118 | *unbillable += entry.UnbillableDuration 119 | p.writer.AppendRow(p.convertEntryToRow(&entry)) 120 | } 121 | } 122 | 123 | func (p *tablePrinter) Print(completeEntries worklog.Entries, incompleteEntries worklog.Entries) error { 124 | var totalBillable time.Duration 125 | var totalUnbillable time.Duration 126 | 127 | var header table.Row 128 | for _, column := range Columns { 129 | header = append(header, column) 130 | } 131 | 132 | p.writer.AppendHeader(header) 133 | 134 | p.generateRows(incompleteEntries, &totalBillable, &totalUnbillable) 135 | p.generateRows(completeEntries, &totalBillable, &totalUnbillable) 136 | 137 | p.writer.AppendFooter(table.Row{ 138 | "", "", "", "", "", "total time spent", totalBillable.String(), totalUnbillable.String(), 139 | }) 140 | p.writer.SetCaption( 141 | "You have %d complete and %d incomplete items. Before proceeding, please double-check them.\n", 142 | len(completeEntries), 143 | len(incompleteEntries), 144 | ) 145 | p.writer.Render() 146 | 147 | return nil 148 | } 149 | 150 | // NewTablePrinter returns a new Printer that print tables to os.Stdout. 151 | func NewTablePrinter(opts *TablePrinterOpts) Printer { 152 | writer := table.NewWriter() 153 | writer.SetOutputMirror(opts.Output) 154 | 155 | writer.SetTitle(opts.Title) 156 | writer.SetAutoIndex(opts.AutoIndex) 157 | 158 | writer.SetStyle(opts.Style) 159 | writer.Style().Format.Footer = text.FormatLower 160 | writer.SetColumnConfigs(opts.ColumnConfig) 161 | 162 | var sortBy []table.SortBy 163 | for _, column := range viper.GetStringSlice("table-sort-by") { 164 | mode := table.Asc 165 | 166 | if strings.HasPrefix(column, "-") { 167 | mode = table.Dsc 168 | } 169 | 170 | sortBy = append(sortBy, table.SortBy{ 171 | Name: column, 172 | Mode: mode, 173 | }) 174 | } 175 | 176 | writer.SortBy(sortBy) 177 | 178 | return &tablePrinter{ 179 | writer: writer, 180 | truncateMap: opts.ColumnTruncates, 181 | } 182 | } 183 | 184 | // ParseColumnConfigs parses the column configs taken from the config file. 185 | // The hidden columns can be defined as flags and column config as well. During 186 | // parsing, the flag based columns will take precedence. 187 | func ParseColumnConfigs(key string, hiddenColumns []string) []table.ColumnConfig { 188 | var columnConfigs []table.ColumnConfig 189 | 190 | for _, column := range Columns { 191 | columnConfig := table.ColumnConfig{ 192 | Name: column, 193 | } 194 | 195 | err := viper.UnmarshalKey(fmt.Sprintf(key, column), &columnConfig) 196 | cobra.CheckErr(err) 197 | 198 | if IsSliceContains(column, hiddenColumns) { 199 | columnConfig.Hidden = true 200 | } 201 | 202 | columnConfigs = append(columnConfigs, columnConfig) 203 | } 204 | 205 | return columnConfigs 206 | } 207 | -------------------------------------------------------------------------------- /internal/cmd/utils/tracker.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/jedib0t/go-pretty/v6/progress" 7 | ) 8 | 9 | // NewProgressWriter returns a pre-configured progress writer. 10 | func NewProgressWriter(updateFrequency time.Duration) progress.Writer { 11 | writer := progress.NewWriter() 12 | 13 | writer.SetAutoStop(true) 14 | writer.SetTrackerPosition(progress.PositionRight) 15 | 16 | writer.SetMessageLength(50) 17 | writer.SetUpdateFrequency(updateFrequency) 18 | 19 | writer.Style().Colors = progress.StyleColorsDefault 20 | writer.Style().Options.DoneString = "uploaded!" 21 | writer.Style().Options.ErrorString = "failed! " // Have the same length as DoneString 22 | writer.Style().Options.Separator = "\t" 23 | writer.Style().Options.SnipIndicator = "..." 24 | writer.Style().Visibility.Time = true 25 | writer.Style().Visibility.Tracker = false 26 | writer.Style().Visibility.Value = false 27 | 28 | return writer 29 | } 30 | -------------------------------------------------------------------------------- /internal/cmd/utils/tracker_test.go: -------------------------------------------------------------------------------- 1 | package utils_test 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | "time" 7 | 8 | "github.com/gabor-boros/minutes/internal/cmd/utils" 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | func TestNewProgressWriter(t *testing.T) { 13 | progressWriter := utils.NewProgressWriter(time.Millisecond * 100) 14 | require.Equal(t, "*progress.Progress", reflect.TypeOf(progressWriter).String()) 15 | } 16 | -------------------------------------------------------------------------------- /internal/cmd/utils/utils.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "os" 7 | "strings" 8 | "time" 9 | 10 | "github.com/spf13/cobra" 11 | ) 12 | 13 | // Truncate chops the text at length and replaces the remaining with "...". 14 | func Truncate(text string, length int) string { 15 | if length >= len(text) || length <= 0 { 16 | return text 17 | } 18 | 19 | truncated := "" 20 | maxLength := length - 3 21 | 22 | for i, char := range text { 23 | if i >= maxLength { 24 | break 25 | } 26 | 27 | truncated += string(char) 28 | } 29 | 30 | return truncated + "..." 31 | } 32 | 33 | // IsSliceContains checks if a string slice contains the given element or not. 34 | func IsSliceContains(entry string, slice []string) bool { 35 | for _, s := range slice { 36 | if s == entry { 37 | return true 38 | } 39 | } 40 | 41 | return false 42 | } 43 | 44 | // Prompt shows the user a message and asks for input, then returns that. 45 | func Prompt(message string) string { 46 | fmt.Print(message) 47 | 48 | reader := bufio.NewReader(os.Stdin) 49 | input, err := reader.ReadString('\n') 50 | cobra.CheckErr(err) 51 | 52 | return strings.TrimSpace(input) 53 | } 54 | 55 | // GetTime parses a string based on the given format and returns the time. 56 | // If the rawDate was an empty string, the today's midnight will return. 57 | func GetTime(rawDate string, dateFormat string) (time.Time, error) { 58 | if rawDate == "" { 59 | year, month, day := time.Now().Date() 60 | return time.Date(year, month, day, 0, 0, 0, 0, time.Local), nil 61 | } 62 | 63 | return time.ParseInLocation(dateFormat, rawDate, time.Local) 64 | } 65 | -------------------------------------------------------------------------------- /internal/cmd/utils/utils_test.go: -------------------------------------------------------------------------------- 1 | package utils_test 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/gabor-boros/minutes/internal/cmd/utils" 8 | 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | func TestTruncate(t *testing.T) { 13 | var truncated string 14 | text := "This is a short text" 15 | 16 | truncated = utils.Truncate(text, len(text)) 17 | require.Equal(t, text, truncated) 18 | 19 | truncated = utils.Truncate(text, 200) 20 | require.Equal(t, text, truncated) 21 | 22 | truncated = utils.Truncate(text, 0) 23 | require.Equal(t, text, truncated) 24 | 25 | truncated = utils.Truncate(text, -1) 26 | require.Equal(t, text, truncated) 27 | 28 | truncated = utils.Truncate(text, 4) 29 | require.Equal(t, "T...", truncated) 30 | 31 | truncated = utils.Truncate(text, 18) 32 | require.Equal(t, "This is a short...", truncated) 33 | } 34 | 35 | func TestIsSliceContains(t *testing.T) { 36 | require.False(t, utils.IsSliceContains("test", []string{})) 37 | require.True(t, utils.IsSliceContains("test", []string{"test"})) 38 | require.False(t, utils.IsSliceContains("test", []string{"testing"})) 39 | require.True(t, utils.IsSliceContains("test", []string{"testing", "test"})) 40 | } 41 | 42 | func TestGetTime(t *testing.T) { 43 | var parsed time.Time 44 | var err error 45 | 46 | year, month, day := time.Now().Date() 47 | 48 | parsed, err = utils.GetTime("2021-01-01 01:00:00", "2006-01-02 15:04:05") 49 | require.Nil(t, err) 50 | require.Equal(t, time.Date(2021, 1, 1, 1, 0, 0, 0, time.Local), parsed) 51 | 52 | parsed, err = utils.GetTime("", "2006-01-02") 53 | require.Nil(t, err) 54 | require.Equal(t, time.Date(year, month, day, 0, 0, 0, 0, time.Local), parsed) 55 | } 56 | -------------------------------------------------------------------------------- /internal/pkg/client/client.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "errors" 8 | "fmt" 9 | "io" 10 | "net/http" 11 | netURL "net/url" 12 | "os/exec" 13 | "reflect" 14 | "strconv" 15 | "time" 16 | 17 | "github.com/gabor-boros/minutes/internal/pkg/worklog" 18 | ) 19 | 20 | const ( 21 | // DefaultRequestTimeout sets the timeout for the HTTP requests or command 22 | // executions. 23 | DefaultRequestTimeout = time.Second * 30 24 | ) 25 | 26 | var ( 27 | // ErrNoBaseURL returns when HTTP based clients has no BaseURL set, but its 28 | // `URL()` method was called. 29 | ErrNoBaseURL = errors.New("no BaseURL provided") 30 | // ErrInvalidBasicAuth returns if any of the provided basic auth parameters 31 | // are empty. 32 | ErrInvalidBasicAuth = errors.New("invalid basic auth params provided") 33 | // ErrInvalidTokenAuth returns if the provided token is empty. 34 | ErrInvalidTokenAuth = errors.New("invalid token auth params provided") 35 | ) 36 | 37 | // BaseClientOpts specifies the common options the clients are using. 38 | // When a client needs other options as well, it composes a new set of options 39 | // using BaseClientOpts. 40 | type BaseClientOpts struct { 41 | // Timeout sets the timeout for the client to execute a request. 42 | // In the case of HTTP clients, the timeout is applied on the HTTP request, 43 | // while in the case of CLI based clients it will be applied on the command 44 | // execution. 45 | Timeout time.Duration 46 | } 47 | 48 | // Authenticator is responsible for setting the necessary parameters for 49 | // authentication on the request. 50 | type Authenticator interface { 51 | // SetAuthHeader sets the auth header on HTTP requests before the HTTPClient 52 | // sends it. 53 | SetAuthHeader(req *http.Request) 54 | } 55 | 56 | // BasicAuth represents the required parameters for username and password based 57 | // authentication 58 | type BasicAuth struct { 59 | Username string 60 | Password string 61 | } 62 | 63 | func (a *BasicAuth) SetAuthHeader(req *http.Request) { 64 | req.SetBasicAuth(a.Username, a.Password) 65 | } 66 | 67 | // NewBasicAuth returns a new BasicAuth that implements Authenticator. 68 | func NewBasicAuth(username string, password string) (Authenticator, error) { 69 | if username == "" || password == "" { 70 | return nil, ErrInvalidBasicAuth 71 | } 72 | 73 | return &BasicAuth{ 74 | Username: username, 75 | Password: password, 76 | }, nil 77 | } 78 | 79 | // TokenAuth represents the required parameters for token based authentication. 80 | type TokenAuth struct { 81 | Header string 82 | TokenName string 83 | Token string 84 | } 85 | 86 | func (a *TokenAuth) SetAuthHeader(req *http.Request) { 87 | token := a.Token 88 | 89 | if a.TokenName != "" { 90 | token = a.TokenName + " " + token 91 | } 92 | 93 | req.Header.Set(a.Header, token) 94 | } 95 | 96 | // NewTokenAuth returns a new TokenAuth that implements Authenticator. If the 97 | // header name is not set, the standard "Authorization" header will be used. 98 | func NewTokenAuth(header string, tokenName string, token string) (Authenticator, error) { 99 | if token == "" { 100 | return nil, ErrInvalidTokenAuth 101 | } 102 | 103 | if header == "" { 104 | header = "Authorization" 105 | } 106 | 107 | return &TokenAuth{ 108 | Header: header, 109 | TokenName: tokenName, 110 | Token: token, 111 | }, nil 112 | } 113 | 114 | // CLIExecuteOpts represents the options that CLI client's Execute method 115 | // receives. 116 | type CLIExecuteOpts struct { 117 | Timeout time.Duration 118 | } 119 | 120 | // CLIClient implements a client that communicates with a CLI tool. 121 | // The CommandArguments parameter is not used by CLIClient, but those structs 122 | // that uses it for composition. 123 | type CLIClient struct { 124 | Command string 125 | CommandArguments []string 126 | CommandCtxExecutor func(ctx context.Context, name string, arg ...string) *exec.Cmd 127 | } 128 | 129 | // Execute runs the given CLI command with the specified arguments. 130 | func (c *CLIClient) Execute(ctx context.Context, arguments []string, opts *CLIExecuteOpts) ([]byte, error) { 131 | ctxWithTimeout, cancel := context.WithTimeout(ctx, opts.Timeout) 132 | defer cancel() 133 | 134 | return c.CommandCtxExecutor(ctxWithTimeout, c.Command, arguments...).Output() // #nosec G204 135 | } 136 | 137 | // HTTPRequestOpts represents the call options for an HTTP request, fired by the 138 | // HTTPClient when `Call` method is called. 139 | type HTTPRequestOpts struct { 140 | Method string 141 | Url string 142 | Data interface{} 143 | Headers map[string]string 144 | Auth Authenticator 145 | Timeout time.Duration 146 | } 147 | 148 | // HTTPClient implements a client that communicates with the server over HTTP. 149 | type HTTPClient struct { 150 | Client *http.Client 151 | BaseURL *netURL.URL 152 | } 153 | 154 | // URL returns the BaseURL combined with the provided params as query params if 155 | // the BaseURL is set. Otherwise, it returns an `ErrNoBaseURL` error. 156 | func (c *HTTPClient) URL(path string, params map[string]string) (string, error) { 157 | if c.BaseURL == nil { 158 | return "", ErrNoBaseURL 159 | } 160 | 161 | urlPath, err := netURL.Parse(path) 162 | if err != nil { 163 | return "", err 164 | } 165 | 166 | url := c.BaseURL.ResolveReference(urlPath) 167 | 168 | query := url.Query() 169 | 170 | for key, val := range params { 171 | query.Set(key, val) 172 | } 173 | 174 | url.RawQuery = query.Encode() 175 | return url.String(), nil 176 | } 177 | 178 | // Call fires an HTTP request with the given method and body (in its body) to 179 | // the API URL returned by the `URL` method. 180 | func (c *HTTPClient) Call(ctx context.Context, opts *HTTPRequestOpts) ([]byte, error) { 181 | ctxWithTimeout, cancel := context.WithTimeout(ctx, opts.Timeout) 182 | defer cancel() 183 | 184 | req, err := c.newRequest(ctxWithTimeout, opts) 185 | if err != nil { 186 | return nil, err 187 | } 188 | 189 | resp, err := c.sendRequest(c.Client, req) 190 | if err != nil { 191 | return nil, err 192 | } 193 | 194 | return io.ReadAll(resp.Body) 195 | } 196 | 197 | // PaginatedFetch fetches the entries from the given paginated API. 198 | // I helps working with paginated APIs and gives a unified entrypoint 199 | // to fetch and parse entries. 200 | // TODO: Write separate unit tests 201 | func (c *HTTPClient) PaginatedFetch(ctx context.Context, opts *PaginatedFetchOpts) (worklog.Entries, error) { 202 | var entries worklog.Entries 203 | 204 | currentPage := 1 205 | 206 | pageSize := opts.PageSize 207 | if pageSize <= 0 { 208 | pageSize = DefaultPageSize 209 | } 210 | 211 | pageSizeParam := opts.PageSizeParam 212 | if pageSizeParam == "" { 213 | pageSizeParam = DefaultPageSizeParam 214 | } 215 | 216 | pageParam := opts.PageParam 217 | if pageParam == "" { 218 | pageParam = DefaultPageParam 219 | } 220 | 221 | for { 222 | url, err := c.URL(opts.URL, map[string]string{ 223 | pageParam: strconv.Itoa(currentPage), 224 | pageSizeParam: strconv.Itoa(pageSize), 225 | }) 226 | 227 | if err != nil { 228 | return nil, fmt.Errorf("%v: %v", ErrFetchEntries, err) 229 | } 230 | 231 | rawEntries, paginatedResponse, err := opts.FetchFunc(ctx, url) 232 | if err != nil { 233 | return nil, fmt.Errorf("%v: %v", ErrFetchEntries, err) 234 | } 235 | 236 | // No entries were returned, no need to parse entries 237 | if reflect.ValueOf(rawEntries).Len() == 0 { 238 | break 239 | } 240 | 241 | parsedEntries, err := opts.ParseFunc(rawEntries, opts.BaseFetchOpts) 242 | if err != nil { 243 | return nil, fmt.Errorf("%v: %v", ErrFetchEntries, err) 244 | } 245 | 246 | entries = append(entries, parsedEntries...) 247 | 248 | if paginatedResponse.EntriesPerPage > 0 { 249 | pageSize = paginatedResponse.EntriesPerPage 250 | } 251 | 252 | // If the number of entries known, break the loop if all entries are fetched 253 | if paginatedResponse.TotalEntries > 0 { 254 | if paginatedResponse.TotalEntries-pageSize*currentPage <= 0 { 255 | break 256 | } 257 | } 258 | 259 | currentPage++ 260 | } 261 | 262 | return entries, nil 263 | } 264 | 265 | func (c *HTTPClient) newRequest(ctx context.Context, opts *HTTPRequestOpts) (*http.Request, error) { 266 | var err error 267 | var body []byte 268 | 269 | if opts.Data != nil { 270 | body, err = json.Marshal(opts.Data) 271 | if err != nil { 272 | return nil, err 273 | } 274 | } 275 | 276 | req, err := http.NewRequestWithContext(ctx, opts.Method, opts.Url, bytes.NewBuffer(body)) 277 | if err != nil { 278 | return nil, err 279 | } 280 | 281 | for key, val := range opts.Headers { 282 | req.Header.Set(key, val) 283 | } 284 | 285 | if opts.Auth != nil { 286 | opts.Auth.SetAuthHeader(req) 287 | } 288 | 289 | return req, err 290 | } 291 | 292 | func (c *HTTPClient) sendRequest(httpClient *http.Client, req *http.Request) (*http.Response, error) { 293 | // Set a default HTTP client if no clients were set 294 | if httpClient == nil { 295 | httpClient = http.DefaultClient 296 | } 297 | 298 | resp, err := httpClient.Do(req) 299 | if err != nil { 300 | return nil, err 301 | } 302 | 303 | // If the response wasn't successful, return an error containing the error code 304 | // https://developer.mozilla.org/en-US/docs/Web/HTTP/Status#successful_responses 305 | if !(resp.StatusCode >= 200 && resp.StatusCode < 300) { 306 | errBody, err := io.ReadAll(resp.Body) 307 | if err != nil { 308 | return nil, err 309 | } 310 | 311 | return nil, fmt.Errorf("%d: %s", resp.StatusCode, string(errBody)) 312 | } 313 | 314 | return resp, nil 315 | } 316 | -------------------------------------------------------------------------------- /internal/pkg/client/clockify/clockify.go: -------------------------------------------------------------------------------- 1 | package clockify 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | "net/url" 9 | "time" 10 | 11 | "strconv" 12 | 13 | "github.com/gabor-boros/minutes/internal/pkg/client" 14 | "github.com/gabor-boros/minutes/internal/pkg/utils" 15 | "github.com/gabor-boros/minutes/internal/pkg/worklog" 16 | ) 17 | 18 | const ( 19 | // PathWorklog is the API endpoint used to search and create worklogs. 20 | PathWorklog string = "/api/v1/workspaces/%s/user/%s/time-entries" 21 | ) 22 | 23 | // Project represents the project assigned to an entry. 24 | type Project struct { 25 | worklog.IDNameField 26 | ClientID string `json:"clientId"` 27 | ClientName string `json:"clientName"` 28 | } 29 | 30 | // Interval represents the Start and End date of an entry. 31 | type Interval struct { 32 | Start time.Time `json:"start"` 33 | End time.Time `json:"end"` 34 | } 35 | 36 | // FetchEntry represents the entry fetched from Clockify. 37 | type FetchEntry struct { 38 | Description string `json:"description"` 39 | Billable bool `json:"billable"` 40 | Project Project `json:"project"` 41 | TimeInterval Interval `json:"timeInterval"` 42 | Task worklog.IDNameField `json:"task"` 43 | Tags []worklog.IDNameField `json:"tags"` 44 | } 45 | 46 | // WorklogSearchParams represents the parameters used to filter search results. 47 | // Hydrated indicates to return the "expanded" search result. Expanded result 48 | // contains the project, task, and tag details, not just their ID. 49 | type WorklogSearchParams struct { 50 | Start string 51 | End string 52 | Page int 53 | PageSize int 54 | Hydrated bool 55 | InProgress bool 56 | } 57 | 58 | // ClientOpts is the client specific options, extending client.BaseClientOpts. 59 | type ClientOpts struct { 60 | client.BaseClientOpts 61 | client.TokenAuth 62 | BaseURL string 63 | Workspace string 64 | } 65 | 66 | type clockifyClient struct { 67 | *client.BaseClientOpts 68 | *client.HTTPClient 69 | authenticator client.Authenticator 70 | workspace string 71 | } 72 | 73 | func (c *clockifyClient) parseEntries(rawEntries interface{}, opts *client.FetchOpts) (worklog.Entries, error) { 74 | var entries worklog.Entries 75 | 76 | fetchedEntries, ok := rawEntries.([]FetchEntry) 77 | if !ok { 78 | return nil, fmt.Errorf("%v: %s", client.ErrFetchEntries, "cannot parse returned entries") 79 | } 80 | 81 | for _, entry := range fetchedEntries { 82 | billableDuration := entry.TimeInterval.End.Sub(entry.TimeInterval.Start) 83 | unbillableDuration := time.Duration(0) 84 | 85 | if !entry.Billable { 86 | unbillableDuration = billableDuration 87 | billableDuration = 0 88 | } 89 | 90 | worklogEntry := worklog.Entry{ 91 | Client: worklog.IDNameField{ 92 | ID: entry.Project.ClientID, 93 | Name: entry.Project.ClientName, 94 | }, 95 | Project: worklog.IDNameField{ 96 | ID: entry.Project.ID, 97 | Name: entry.Project.Name, 98 | }, 99 | Task: worklog.IDNameField{ 100 | ID: entry.Task.ID, 101 | Name: entry.Task.Name, 102 | }, 103 | Summary: entry.Task.Name, 104 | Notes: entry.Description, 105 | Start: entry.TimeInterval.Start, 106 | BillableDuration: billableDuration, 107 | UnbillableDuration: unbillableDuration, 108 | } 109 | 110 | // If the entry's summary is empty, but we have notes, let's use notes for summary too 111 | // See: https://github.com/gabor-boros/minutes/issues/38 112 | if worklogEntry.Summary == "" && worklogEntry.Notes != "" { 113 | worklogEntry.Summary = worklogEntry.Notes 114 | } 115 | 116 | if utils.IsRegexSet(opts.TagsAsTasksRegex) && len(entry.Tags) > 0 { 117 | pageEntries := worklogEntry.SplitByTagsAsTasks(entry.Description, opts.TagsAsTasksRegex, entry.Tags) 118 | entries = append(entries, pageEntries...) 119 | } else { 120 | entries = append(entries, worklogEntry) 121 | } 122 | } 123 | 124 | return entries, nil 125 | } 126 | 127 | func (c *clockifyClient) fetchEntries(ctx context.Context, reqURL string) (interface{}, *client.PaginatedFetchResponse, error) { 128 | resp, err := c.Call(ctx, &client.HTTPRequestOpts{ 129 | Method: http.MethodGet, 130 | Url: reqURL, 131 | Auth: c.authenticator, 132 | Timeout: c.Timeout, 133 | }) 134 | 135 | if err != nil { 136 | return nil, nil, fmt.Errorf("%v: %v", client.ErrFetchEntries, err) 137 | } 138 | 139 | var fetchedEntries []FetchEntry 140 | if err = json.Unmarshal(resp, &fetchedEntries); err != nil { 141 | return nil, nil, fmt.Errorf("%v: %v", client.ErrFetchEntries, err) 142 | } 143 | 144 | return fetchedEntries, &client.PaginatedFetchResponse{}, err 145 | } 146 | 147 | func (c *clockifyClient) FetchEntries(ctx context.Context, opts *client.FetchOpts) (worklog.Entries, error) { 148 | fetchURL, err := c.URL(fmt.Sprintf(PathWorklog, c.workspace, opts.User), map[string]string{ 149 | "start": utils.DateFormatRFC3339UTC.Format(opts.Start.Local()), 150 | "end": utils.DateFormatRFC3339UTC.Format(opts.End.Local()), 151 | "hydrated": strconv.FormatBool(true), 152 | "in-progress": strconv.FormatBool(false), 153 | }) 154 | 155 | if err != nil { 156 | return nil, fmt.Errorf("%v: %v", client.ErrFetchEntries, err) 157 | } 158 | 159 | return c.PaginatedFetch(ctx, &client.PaginatedFetchOpts{ 160 | BaseFetchOpts: opts, 161 | URL: fetchURL, 162 | PageSizeParam: "page-size", 163 | FetchFunc: c.fetchEntries, 164 | ParseFunc: c.parseEntries, 165 | }) 166 | } 167 | 168 | // NewFetcher returns a new Clockify client for fetching entries. 169 | func NewFetcher(opts *ClientOpts) (client.Fetcher, error) { 170 | baseURL, err := url.Parse(opts.BaseURL) 171 | if err != nil { 172 | return nil, err 173 | } 174 | 175 | authenticator, err := client.NewTokenAuth(opts.Header, "", opts.Token) 176 | if err != nil { 177 | return nil, err 178 | } 179 | 180 | return &clockifyClient{ 181 | authenticator: authenticator, 182 | HTTPClient: &client.HTTPClient{BaseURL: baseURL}, 183 | BaseClientOpts: &opts.BaseClientOpts, 184 | workspace: opts.Workspace, 185 | }, nil 186 | } 187 | -------------------------------------------------------------------------------- /internal/pkg/client/clockify/clockify_test.go: -------------------------------------------------------------------------------- 1 | package clockify_test 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | "net/http/httptest" 9 | "regexp" 10 | "testing" 11 | "time" 12 | 13 | "github.com/gabor-boros/minutes/internal/pkg/client" 14 | "github.com/gabor-boros/minutes/internal/pkg/client/clockify" 15 | "github.com/gabor-boros/minutes/internal/pkg/worklog" 16 | "github.com/stretchr/testify/require" 17 | ) 18 | 19 | type mockServerOpts struct { 20 | Path string 21 | Method string 22 | StatusCode int 23 | Token string 24 | TokenHeader string 25 | ResponseData *[]clockify.FetchEntry 26 | RemainingCalls *int 27 | } 28 | 29 | func mockServer(t *testing.T, e *mockServerOpts) *httptest.Server { 30 | return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 31 | require.Equal(t, e.Method, r.Method, "API call methods are not matching") 32 | require.Equal(t, e.Path, r.URL.Path, "API call URLs are not matching") 33 | 34 | if *e.RemainingCalls == 0 { 35 | w.WriteHeader(http.StatusOK) 36 | _ = json.NewEncoder(w).Encode(&[]clockify.FetchEntry{}) 37 | return 38 | } 39 | 40 | if e.Token != "" { 41 | headerValue := r.Header.Get(e.TokenHeader) 42 | require.Equal(t, e.Token, headerValue, "API call auth token mismatch") 43 | } 44 | 45 | if e.ResponseData != nil { 46 | err := json.NewEncoder(w).Encode(e.ResponseData) 47 | require.Nil(t, err, "cannot encode response data") 48 | } 49 | 50 | *e.RemainingCalls-- 51 | w.WriteHeader(e.StatusCode) 52 | })) 53 | } 54 | 55 | func newMockServer(t *testing.T, opts *mockServerOpts) *httptest.Server { 56 | mockServer := mockServer(t, opts) 57 | require.NotNil(t, mockServer, "cannot create mock server") 58 | return mockServer 59 | } 60 | 61 | func TestClockifyClient_FetchEntries(t *testing.T) { 62 | start := time.Date(2021, 10, 2, 0, 0, 0, 0, time.UTC) 63 | end := time.Date(2021, 10, 2, 23, 59, 59, 0, time.UTC) 64 | remainingCalls := 1 65 | 66 | expectedEntries := worklog.Entries{ 67 | { 68 | Client: worklog.IDNameField{ 69 | ID: "456", 70 | Name: "My Awesome Company", 71 | }, 72 | Project: worklog.IDNameField{ 73 | ID: "123", 74 | Name: "MARVEL-101", 75 | }, 76 | Task: worklog.IDNameField{ 77 | ID: "789", 78 | Name: "Meet with Iron Man", 79 | }, 80 | Summary: "Meet with Iron Man", 81 | Notes: "Have a coffee with Tony", 82 | Start: start, 83 | BillableDuration: end.Sub(start), 84 | UnbillableDuration: 0, 85 | }, 86 | { 87 | Client: worklog.IDNameField{ 88 | ID: "456", 89 | Name: "My Awesome Company", 90 | }, 91 | Project: worklog.IDNameField{ 92 | ID: "123", 93 | Name: "MARVEL-101", 94 | }, 95 | Task: worklog.IDNameField{ 96 | ID: "789", 97 | Name: "Meet with Iron Man", 98 | }, 99 | Summary: "Meet with Iron Man", 100 | Notes: "Go back for my wallet", 101 | Start: start, 102 | BillableDuration: 0, 103 | UnbillableDuration: end.Sub(start), 104 | }, 105 | } 106 | 107 | mockServer := newMockServer(t, &mockServerOpts{ 108 | Path: fmt.Sprintf(clockify.PathWorklog, "marvel-studios", "steve-rogers"), 109 | Method: http.MethodGet, 110 | StatusCode: http.StatusOK, 111 | Token: "t-o-k-e-n", 112 | TokenHeader: "X-Api-Key", 113 | RemainingCalls: &remainingCalls, 114 | ResponseData: &[]clockify.FetchEntry{ 115 | { 116 | Description: "Have a coffee with Tony", 117 | Billable: true, 118 | Project: clockify.Project{ 119 | IDNameField: worklog.IDNameField{ 120 | ID: "123", 121 | Name: "MARVEL-101", 122 | }, 123 | ClientID: "456", 124 | ClientName: "My Awesome Company", 125 | }, 126 | TimeInterval: clockify.Interval{ 127 | Start: start, 128 | End: end, 129 | }, 130 | Task: worklog.IDNameField{ 131 | ID: "789", 132 | Name: "Meet with Iron Man", 133 | }, 134 | Tags: []worklog.IDNameField{ 135 | { 136 | ID: "1234", 137 | Name: "Coffee", 138 | }, 139 | { 140 | ID: "5678", 141 | Name: "Meeting", 142 | }, 143 | { 144 | ID: "9876", 145 | Name: "TASK-1234", 146 | }, 147 | }, 148 | }, 149 | { 150 | Description: "Go back for my wallet", 151 | Billable: false, 152 | Project: clockify.Project{ 153 | IDNameField: worklog.IDNameField{ 154 | ID: "123", 155 | Name: "MARVEL-101", 156 | }, 157 | ClientID: "456", 158 | ClientName: "My Awesome Company", 159 | }, 160 | TimeInterval: clockify.Interval{ 161 | Start: start, 162 | End: end, 163 | }, 164 | Task: worklog.IDNameField{ 165 | ID: "789", 166 | Name: "Meet with Iron Man", 167 | }, 168 | Tags: []worklog.IDNameField{ 169 | { 170 | ID: "1234", 171 | Name: "Coffee", 172 | }, 173 | { 174 | ID: "5678", 175 | Name: "Meeting", 176 | }, 177 | { 178 | ID: "9876", 179 | Name: "TASK-1234", 180 | }, 181 | { 182 | ID: "5432", 183 | Name: "TASK-5678", 184 | }, 185 | }, 186 | }, 187 | }, 188 | }) 189 | defer mockServer.Close() 190 | 191 | clockifyClient, err := clockify.NewFetcher(&clockify.ClientOpts{ 192 | BaseClientOpts: client.BaseClientOpts{ 193 | Timeout: client.DefaultRequestTimeout, 194 | }, 195 | TokenAuth: client.TokenAuth{ 196 | Header: "X-Api-Key", 197 | Token: "t-o-k-e-n", 198 | }, 199 | BaseURL: mockServer.URL, 200 | Workspace: "marvel-studios", 201 | }) 202 | 203 | require.Nil(t, err) 204 | 205 | entries, err := clockifyClient.FetchEntries(context.Background(), &client.FetchOpts{ 206 | User: "steve-rogers", 207 | Start: start, 208 | End: end, 209 | }) 210 | 211 | require.Nil(t, err, "cannot fetch entries") 212 | require.ElementsMatch(t, expectedEntries, entries, "fetched entries are not matching") 213 | } 214 | 215 | func TestClockifyClient_FetchEntries_TagsAsTasks(t *testing.T) { 216 | start := time.Date(2021, 10, 2, 0, 0, 0, 0, time.UTC) 217 | end := time.Date(2021, 10, 2, 23, 59, 59, 0, time.UTC) 218 | remainingCalls := 1 219 | 220 | expectedEntries := worklog.Entries{ 221 | { 222 | Client: worklog.IDNameField{ 223 | ID: "456", 224 | Name: "My Awesome Company", 225 | }, 226 | Project: worklog.IDNameField{ 227 | ID: "123", 228 | Name: "MARVEL-101", 229 | }, 230 | Task: worklog.IDNameField{ 231 | ID: "9876", 232 | Name: "TASK-1234", 233 | }, 234 | Summary: "Have a coffee with Tony", 235 | Notes: "Have a coffee with Tony", 236 | Start: start, 237 | BillableDuration: end.Sub(start), 238 | UnbillableDuration: 0, 239 | }, 240 | { 241 | Client: worklog.IDNameField{ 242 | ID: "456", 243 | Name: "My Awesome Company", 244 | }, 245 | Project: worklog.IDNameField{ 246 | ID: "123", 247 | Name: "MARVEL-101", 248 | }, 249 | Task: worklog.IDNameField{ 250 | ID: "9876", 251 | Name: "TASK-1234", 252 | }, 253 | Summary: "Go back for my wallet", 254 | Notes: "Go back for my wallet", 255 | Start: start, 256 | BillableDuration: 0, 257 | UnbillableDuration: end.Sub(start) / 2, 258 | }, 259 | { 260 | Client: worklog.IDNameField{ 261 | ID: "456", 262 | Name: "My Awesome Company", 263 | }, 264 | Project: worklog.IDNameField{ 265 | ID: "123", 266 | Name: "MARVEL-101", 267 | }, 268 | Task: worklog.IDNameField{ 269 | ID: "5432", 270 | Name: "TASK-5678", 271 | }, 272 | Summary: "Go back for my wallet", 273 | Notes: "Go back for my wallet", 274 | Start: start, 275 | BillableDuration: 0, 276 | UnbillableDuration: end.Sub(start) / 2, 277 | }, 278 | } 279 | 280 | mockServer := newMockServer(t, &mockServerOpts{ 281 | Path: fmt.Sprintf(clockify.PathWorklog, "marvel-studios", "steve-rogers"), 282 | Method: http.MethodGet, 283 | StatusCode: http.StatusOK, 284 | Token: "t-o-k-e-n", 285 | TokenHeader: "X-Api-Key", 286 | RemainingCalls: &remainingCalls, 287 | ResponseData: &[]clockify.FetchEntry{ 288 | { 289 | Description: "Have a coffee with Tony", 290 | Billable: true, 291 | Project: clockify.Project{ 292 | IDNameField: worklog.IDNameField{ 293 | ID: "123", 294 | Name: "MARVEL-101", 295 | }, 296 | ClientID: "456", 297 | ClientName: "My Awesome Company", 298 | }, 299 | TimeInterval: clockify.Interval{ 300 | Start: start, 301 | End: end, 302 | }, 303 | Task: worklog.IDNameField{}, 304 | Tags: []worklog.IDNameField{ 305 | { 306 | ID: "1234", 307 | Name: "Coffee", 308 | }, 309 | { 310 | ID: "5678", 311 | Name: "Meeting", 312 | }, 313 | { 314 | ID: "9876", 315 | Name: "TASK-1234", 316 | }, 317 | }, 318 | }, 319 | { 320 | Description: "Go back for my wallet", 321 | Billable: false, 322 | Project: clockify.Project{ 323 | IDNameField: worklog.IDNameField{ 324 | ID: "123", 325 | Name: "MARVEL-101", 326 | }, 327 | ClientID: "456", 328 | ClientName: "My Awesome Company", 329 | }, 330 | TimeInterval: clockify.Interval{ 331 | Start: start, 332 | End: end, 333 | }, 334 | Task: worklog.IDNameField{}, 335 | Tags: []worklog.IDNameField{ 336 | { 337 | ID: "1234", 338 | Name: "Coffee", 339 | }, 340 | { 341 | ID: "5678", 342 | Name: "Meeting", 343 | }, 344 | { 345 | ID: "9876", 346 | Name: "TASK-1234", 347 | }, 348 | { 349 | ID: "5432", 350 | Name: "TASK-5678", 351 | }, 352 | }, 353 | }, 354 | }, 355 | }) 356 | defer mockServer.Close() 357 | 358 | clockifyClient, err := clockify.NewFetcher(&clockify.ClientOpts{ 359 | BaseClientOpts: client.BaseClientOpts{ 360 | Timeout: client.DefaultRequestTimeout, 361 | }, 362 | TokenAuth: client.TokenAuth{ 363 | Header: "X-Api-Key", 364 | Token: "t-o-k-e-n", 365 | }, 366 | BaseURL: mockServer.URL, 367 | Workspace: "marvel-studios", 368 | }) 369 | 370 | require.Nil(t, err) 371 | 372 | entries, err := clockifyClient.FetchEntries(context.Background(), &client.FetchOpts{ 373 | User: "steve-rogers", 374 | Start: start, 375 | End: end, 376 | TagsAsTasksRegex: regexp.MustCompile(`^TASK-\d+$`), 377 | }) 378 | 379 | require.Nil(t, err, "cannot fetch entries") 380 | require.ElementsMatch(t, expectedEntries, entries, "fetched entries are not matching") 381 | } 382 | 383 | func TestClockifyClient_FetchEntries_TagsAsTasks_NoTags(t *testing.T) { 384 | start := time.Date(2021, 10, 2, 0, 0, 0, 0, time.UTC) 385 | end := time.Date(2021, 10, 2, 23, 59, 59, 0, time.UTC) 386 | remainingCalls := 1 387 | 388 | expectedEntries := worklog.Entries{ 389 | { 390 | Client: worklog.IDNameField{ 391 | ID: "456", 392 | Name: "My Awesome Company", 393 | }, 394 | Project: worklog.IDNameField{ 395 | ID: "123", 396 | Name: "TASK-1234", 397 | }, 398 | Task: worklog.IDNameField{}, 399 | Summary: "Have a coffee with Tony", 400 | Notes: "Have a coffee with Tony", 401 | Start: start, 402 | BillableDuration: end.Sub(start), 403 | UnbillableDuration: 0, 404 | }, 405 | } 406 | 407 | mockServer := newMockServer(t, &mockServerOpts{ 408 | Path: fmt.Sprintf(clockify.PathWorklog, "marvel-studios", "steve-rogers"), 409 | Method: http.MethodGet, 410 | StatusCode: http.StatusOK, 411 | Token: "t-o-k-e-n", 412 | TokenHeader: "X-Api-Key", 413 | RemainingCalls: &remainingCalls, 414 | ResponseData: &[]clockify.FetchEntry{ 415 | { 416 | Description: "Have a coffee with Tony", 417 | Billable: true, 418 | Project: clockify.Project{ 419 | IDNameField: worklog.IDNameField{ 420 | ID: "123", 421 | Name: "TASK-1234", 422 | }, 423 | ClientID: "456", 424 | ClientName: "My Awesome Company", 425 | }, 426 | TimeInterval: clockify.Interval{ 427 | Start: start, 428 | End: end, 429 | }, 430 | Task: worklog.IDNameField{}, 431 | Tags: []worklog.IDNameField{}, 432 | }, 433 | }, 434 | }) 435 | defer mockServer.Close() 436 | 437 | clockifyClient, err := clockify.NewFetcher(&clockify.ClientOpts{ 438 | BaseClientOpts: client.BaseClientOpts{ 439 | Timeout: client.DefaultRequestTimeout, 440 | }, 441 | TokenAuth: client.TokenAuth{ 442 | Header: "X-Api-Key", 443 | Token: "t-o-k-e-n", 444 | }, 445 | BaseURL: mockServer.URL, 446 | Workspace: "marvel-studios", 447 | }) 448 | 449 | require.Nil(t, err) 450 | 451 | entries, err := clockifyClient.FetchEntries(context.Background(), &client.FetchOpts{ 452 | User: "steve-rogers", 453 | Start: start, 454 | End: end, 455 | TagsAsTasksRegex: regexp.MustCompile(`^TASK-\d+$`), 456 | }) 457 | 458 | require.Nil(t, err, "cannot fetch entries") 459 | require.ElementsMatch(t, expectedEntries, entries, "fetched entries are not matching") 460 | } 461 | -------------------------------------------------------------------------------- /internal/pkg/client/fetcher.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "regexp" 7 | "time" 8 | 9 | "github.com/gabor-boros/minutes/internal/pkg/worklog" 10 | ) 11 | 12 | const ( 13 | // DefaultPageSize used by paginated fetchers setting the fetched page size. 14 | // The minimum page sizes can be different per client, but the 50 items per 15 | // page is usually supported everywhere. 16 | DefaultPageSize int = 50 17 | // DefaultPageSizeParam used by paginated fetchers setting page size parameter. 18 | DefaultPageSizeParam string = "per_page" 19 | // DefaultPageParam used by paginated fetchers setting the page parameter. 20 | DefaultPageParam string = "page" 21 | ) 22 | 23 | var ( 24 | // ErrFetchEntries wraps the error when fetch failed. 25 | ErrFetchEntries = errors.New("failed to fetch entries") 26 | ) 27 | 28 | // FetchOpts specifies the only options for Fetchers. 29 | // In contract to the BaseClientOpts, these options shall not be extended or 30 | // overridden. 31 | type FetchOpts struct { 32 | User string 33 | Start time.Time 34 | End time.Time 35 | 36 | // TagsAsTasksRegex sets the regular expression used for extracting tasks 37 | // from the list of tags. 38 | TagsAsTasksRegex *regexp.Regexp 39 | } 40 | 41 | // Fetcher specifies the functions used to fetch worklog entries. 42 | type Fetcher interface { 43 | // FetchEntries from a given source and return the list of worklog entries 44 | // If the fetching resulted in an error, the list of worklog entries will be 45 | // nil and an error will return. 46 | FetchEntries(ctx context.Context, opts *FetchOpts) (worklog.Entries, error) 47 | } 48 | 49 | type PaginatedFetchResponse struct { 50 | EntriesPerPage int 51 | TotalEntries int 52 | } 53 | 54 | type PaginatedFetchFunc = func(context.Context, string) (interface{}, *PaginatedFetchResponse, error) 55 | type PaginatedParseFunc = func(interface{}, *FetchOpts) (worklog.Entries, error) 56 | 57 | type PaginatedFetchOpts struct { 58 | BaseFetchOpts *FetchOpts 59 | 60 | URL string 61 | PageSize int 62 | PageSizeParam string 63 | PageParam string 64 | 65 | FetchFunc PaginatedFetchFunc 66 | ParseFunc PaginatedParseFunc 67 | } 68 | -------------------------------------------------------------------------------- /internal/pkg/client/harvest/harvest.go: -------------------------------------------------------------------------------- 1 | package harvest 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | "net/url" 9 | "strconv" 10 | "time" 11 | 12 | "github.com/gabor-boros/minutes/internal/pkg/client" 13 | "github.com/gabor-boros/minutes/internal/pkg/utils" 14 | "github.com/gabor-boros/minutes/internal/pkg/worklog" 15 | ) 16 | 17 | const ( 18 | // PathWorklog is the endpoint used to search existing worklogs. 19 | PathWorklog string = "/v2/time_entries" 20 | ) 21 | 22 | // FetchEntry represents the entry fetched from Harvest. 23 | type FetchEntry struct { 24 | Client worklog.IntIDNameField `json:"client"` 25 | Project worklog.IntIDNameField `json:"project"` 26 | Task worklog.IntIDNameField `json:"task"` 27 | Notes string `json:"notes"` 28 | SpentDate string `json:"spent_date"` 29 | Hours float32 `json:"hours"` 30 | CreatedAt time.Time `json:"created_at"` 31 | Billable bool `json:"billable"` 32 | IsRunning bool `json:"is_running"` 33 | } 34 | 35 | // Start returns the start date created from the spent date and created at. 36 | // The spent date represents the date the user wants the entry to be logged, 37 | // e.g: 2021-10-01. The creation date represents the actual creation of the 38 | // entry, e.g: 2021-10-02T10:26:20Z. Since Harvest is not precise with the 39 | // spent date, we have to create a start date from these two entries. This is 40 | // needed, because if the user is manually creating an entry, and creates on 41 | // a wrong date accidentally, after editing the entry, the spent date will be 42 | // updated, though the creation date not. 43 | func (e *FetchEntry) Start() (time.Time, error) { 44 | spentDate, err := utils.DateFormatISO8601.Parse(e.SpentDate) 45 | if err != nil { 46 | return time.Time{}, err 47 | } 48 | 49 | return time.Date( 50 | spentDate.Year(), 51 | spentDate.Month(), 52 | spentDate.Day(), 53 | e.CreatedAt.Hour(), 54 | e.CreatedAt.Minute(), 55 | e.CreatedAt.Second(), 56 | e.CreatedAt.Nanosecond(), 57 | e.CreatedAt.Location(), 58 | ), nil 59 | } 60 | 61 | // FetchResponse represents the relevant response data. 62 | // Although the response contains a lot more information about pagination, it 63 | // cannot be used with the current structure. 64 | type FetchResponse struct { 65 | TimeEntries []FetchEntry `json:"time_entries"` 66 | PerPage int `json:"per_page"` 67 | TotalEntries int `json:"total_entries"` 68 | } 69 | 70 | // ClientOpts is the client specific options, extending client.BaseClientOpts. 71 | type ClientOpts struct { 72 | client.BaseClientOpts 73 | client.TokenAuth 74 | BaseURL string 75 | Account int 76 | } 77 | 78 | type harvestClient struct { 79 | *client.BaseClientOpts 80 | *client.HTTPClient 81 | authenticator client.Authenticator 82 | account int 83 | } 84 | 85 | func (c *harvestClient) parseEntries(rawEntries interface{}, _ *client.FetchOpts) (worklog.Entries, error) { 86 | var entries worklog.Entries 87 | 88 | fetchedEntries, ok := rawEntries.([]FetchEntry) 89 | if !ok { 90 | return nil, fmt.Errorf("%v: %s", client.ErrFetchEntries, "cannot parse returned entries") 91 | } 92 | 93 | for _, fetchedEntry := range fetchedEntries { 94 | startDate, err := fetchedEntry.Start() 95 | if err != nil { 96 | return nil, fmt.Errorf("%v: %v", client.ErrFetchEntries, err) 97 | } 98 | 99 | billableDuration, err := time.ParseDuration(fmt.Sprintf("%fh", fetchedEntry.Hours)) 100 | if err != nil { 101 | return nil, fmt.Errorf("%v: %v", client.ErrFetchEntries, err) 102 | } 103 | 104 | unbillableDuration := time.Duration(0) 105 | 106 | if !fetchedEntry.Billable { 107 | unbillableDuration = billableDuration 108 | billableDuration = 0 109 | } 110 | 111 | entries = append(entries, worklog.Entry{ 112 | Client: fetchedEntry.Client.ConvertToIDNameField(), 113 | Project: fetchedEntry.Project.ConvertToIDNameField(), 114 | Task: fetchedEntry.Task.ConvertToIDNameField(), 115 | Summary: fetchedEntry.Notes, 116 | Notes: fetchedEntry.Notes, 117 | Start: startDate, 118 | BillableDuration: billableDuration, 119 | UnbillableDuration: unbillableDuration, 120 | }) 121 | } 122 | 123 | return entries, nil 124 | } 125 | 126 | func (c *harvestClient) fetchEntries(ctx context.Context, reqURL string) (interface{}, *client.PaginatedFetchResponse, error) { 127 | resp, err := c.Call(ctx, &client.HTTPRequestOpts{ 128 | Method: http.MethodGet, 129 | Url: reqURL, 130 | Auth: c.authenticator, 131 | Timeout: c.Timeout, 132 | Headers: map[string]string{ 133 | "Harvest-Account-ID": strconv.Itoa(c.account), 134 | }, 135 | }) 136 | 137 | if err != nil { 138 | return nil, nil, fmt.Errorf("%v: %v", client.ErrFetchEntries, err) 139 | } 140 | 141 | var fetchResponse FetchResponse 142 | if err = json.Unmarshal(resp, &fetchResponse); err != nil { 143 | return nil, nil, fmt.Errorf("%v: %v", client.ErrFetchEntries, err) 144 | } 145 | 146 | paginatedResponse := &client.PaginatedFetchResponse{ 147 | EntriesPerPage: fetchResponse.PerPage, 148 | TotalEntries: fetchResponse.TotalEntries, 149 | } 150 | 151 | return fetchResponse.TimeEntries, paginatedResponse, err 152 | } 153 | 154 | func (c *harvestClient) FetchEntries(ctx context.Context, opts *client.FetchOpts) (worklog.Entries, error) { 155 | fetchURL, err := c.URL(PathWorklog, map[string]string{ 156 | "from": utils.DateFormatRFC3339UTC.Format(opts.Start), 157 | "to": utils.DateFormatRFC3339UTC.Format(opts.End), 158 | "user_id": opts.User, 159 | "is_running": strconv.FormatBool(false), 160 | "user_agent": "github.com/gabor-boros/minutes", 161 | }) 162 | 163 | if err != nil { 164 | return nil, fmt.Errorf("%v: %v", client.ErrFetchEntries, err) 165 | } 166 | 167 | return c.PaginatedFetch(ctx, &client.PaginatedFetchOpts{ 168 | URL: fetchURL, 169 | FetchFunc: c.fetchEntries, 170 | ParseFunc: c.parseEntries, 171 | }) 172 | } 173 | 174 | // NewFetcher returns a new Clockify client for fetching entries. 175 | func NewFetcher(opts *ClientOpts) (client.Fetcher, error) { 176 | baseURL, err := url.Parse(opts.BaseURL) 177 | if err != nil { 178 | return nil, err 179 | } 180 | 181 | authenticator, err := client.NewTokenAuth(opts.Header, opts.TokenName, opts.Token) 182 | if err != nil { 183 | return nil, err 184 | } 185 | 186 | return &harvestClient{ 187 | BaseClientOpts: &opts.BaseClientOpts, 188 | HTTPClient: &client.HTTPClient{ 189 | BaseURL: baseURL, 190 | }, 191 | authenticator: authenticator, 192 | account: opts.Account, 193 | }, nil 194 | } 195 | -------------------------------------------------------------------------------- /internal/pkg/client/harvest/harvest_test.go: -------------------------------------------------------------------------------- 1 | package harvest_test 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "net/http" 7 | "net/http/httptest" 8 | "net/url" 9 | "testing" 10 | "time" 11 | 12 | "github.com/gabor-boros/minutes/internal/pkg/client" 13 | "github.com/gabor-boros/minutes/internal/pkg/client/harvest" 14 | "github.com/gabor-boros/minutes/internal/pkg/utils" 15 | "github.com/gabor-boros/minutes/internal/pkg/worklog" 16 | 17 | "github.com/stretchr/testify/require" 18 | ) 19 | 20 | type mockServerOpts struct { 21 | Path string 22 | QueryParams url.Values 23 | Method string 24 | StatusCode int 25 | Token string 26 | TokenHeader string 27 | ResponseData *harvest.FetchResponse 28 | } 29 | 30 | func mockServer(t *testing.T, e *mockServerOpts) *httptest.Server { 31 | return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 32 | require.Equal(t, e.Method, r.Method, "API call methods are not matching") 33 | require.Equal(t, e.Path, r.URL.Path, "API call URLs are not matching") 34 | require.Equal(t, e.QueryParams, r.URL.Query()) 35 | 36 | if e.Token != "" { 37 | headerValue := r.Header.Get(e.TokenHeader) 38 | require.Equal(t, e.Token, headerValue, "API call auth token mismatch") 39 | } 40 | 41 | if e.ResponseData != nil { 42 | err := json.NewEncoder(w).Encode(e.ResponseData) 43 | require.Nil(t, err, "cannot encode response data") 44 | } 45 | 46 | w.WriteHeader(e.StatusCode) 47 | })) 48 | } 49 | 50 | func newMockServer(t *testing.T, opts *mockServerOpts) *httptest.Server { 51 | mockServer := mockServer(t, opts) 52 | require.NotNil(t, mockServer, "cannot create mock server") 53 | return mockServer 54 | } 55 | 56 | func TestFetchEntry_Start(t *testing.T) { 57 | expectedStart := time.Date(2021, 9, 30, 23, 59, 59, 0, time.UTC) 58 | 59 | entry := harvest.FetchEntry{ 60 | SpentDate: "2021-09-30", 61 | CreatedAt: time.Date(2021, 10, 1, 23, 59, 59, 0, time.UTC), 62 | } 63 | 64 | startDate, err := entry.Start() 65 | 66 | require.Nil(t, err) 67 | require.Equal(t, expectedStart, startDate) 68 | } 69 | 70 | func TestHarvestClient_FetchEntries(t *testing.T) { 71 | start := time.Date(2021, 10, 2, 0, 0, 0, 0, time.UTC) 72 | end := time.Date(2021, 10, 2, 23, 59, 59, 0, time.UTC) 73 | 74 | expectedEntries := worklog.Entries{ 75 | { 76 | Client: worklog.IDNameField{ 77 | ID: "1", 78 | Name: "My Awesome Company", 79 | }, 80 | Project: worklog.IDNameField{ 81 | ID: "11", 82 | Name: "MARVEL", 83 | }, 84 | Task: worklog.IDNameField{ 85 | ID: "111", 86 | Name: "CPT-2014", 87 | }, 88 | Summary: "I met with The Winter Soldier", 89 | Notes: "I met with The Winter Soldier", 90 | Start: start, 91 | BillableDuration: time.Hour * 2, 92 | UnbillableDuration: 0, 93 | }, 94 | { 95 | Client: worklog.IDNameField{ 96 | ID: "1", 97 | Name: "My Awesome Company", 98 | }, 99 | Project: worklog.IDNameField{ 100 | ID: "11", 101 | Name: "MARVEL", 102 | }, 103 | Task: worklog.IDNameField{ 104 | ID: "111", 105 | Name: "CPT-2014", 106 | }, 107 | Summary: "I helped him to get back on track", 108 | Notes: "I helped him to get back on track", 109 | Start: start, 110 | BillableDuration: 0, 111 | UnbillableDuration: time.Hour * 3, 112 | }, 113 | } 114 | 115 | mockServer := newMockServer(t, &mockServerOpts{ 116 | Path: harvest.PathWorklog, 117 | QueryParams: url.Values{ 118 | "page": {"1"}, 119 | "per_page": {"50"}, 120 | "from": {utils.DateFormatRFC3339UTC.Format(start)}, 121 | "to": {utils.DateFormatRFC3339UTC.Format(end)}, 122 | "user_id": {"987654321"}, 123 | "is_running": {"false"}, 124 | "user_agent": {"github.com/gabor-boros/minutes"}, 125 | }, 126 | Method: http.MethodGet, 127 | StatusCode: http.StatusOK, 128 | Token: "Bearer t-o-k-e-n", 129 | TokenHeader: "Authorization", 130 | ResponseData: &harvest.FetchResponse{ 131 | TimeEntries: []harvest.FetchEntry{ 132 | { 133 | Client: worklog.IntIDNameField{ 134 | ID: 1, 135 | Name: "My Awesome Company", 136 | }, 137 | Project: worklog.IntIDNameField{ 138 | ID: 11, 139 | Name: "MARVEL", 140 | }, 141 | Task: worklog.IntIDNameField{ 142 | ID: 111, 143 | Name: "CPT-2014", 144 | }, 145 | Notes: "I met with The Winter Soldier", 146 | SpentDate: utils.DateFormatISO8601.Format(start), 147 | Hours: 2.0, 148 | CreatedAt: start, 149 | Billable: true, 150 | IsRunning: false, 151 | }, 152 | { 153 | Client: worklog.IntIDNameField{ 154 | ID: 1, 155 | Name: "My Awesome Company", 156 | }, 157 | Project: worklog.IntIDNameField{ 158 | ID: 11, 159 | Name: "MARVEL", 160 | }, 161 | Task: worklog.IntIDNameField{ 162 | ID: 111, 163 | Name: "CPT-2014", 164 | }, 165 | Notes: "I helped him to get back on track", 166 | SpentDate: utils.DateFormatISO8601.Format(start), 167 | Hours: 3.0, 168 | CreatedAt: start, 169 | Billable: false, 170 | IsRunning: false, 171 | }, 172 | }, 173 | PerPage: 50, 174 | TotalEntries: 2, 175 | }, 176 | }) 177 | defer mockServer.Close() 178 | 179 | harvestClient, err := harvest.NewFetcher(&harvest.ClientOpts{ 180 | BaseClientOpts: client.BaseClientOpts{ 181 | Timeout: client.DefaultRequestTimeout, 182 | }, 183 | TokenAuth: client.TokenAuth{ 184 | Header: "Authorization", 185 | TokenName: "Bearer", 186 | Token: "t-o-k-e-n", 187 | }, 188 | BaseURL: mockServer.URL, 189 | Account: 123456789, 190 | }) 191 | require.Nil(t, err) 192 | 193 | entries, err := harvestClient.FetchEntries(context.Background(), &client.FetchOpts{ 194 | User: "987654321", 195 | Start: start, 196 | End: end, 197 | }) 198 | 199 | require.Nil(t, err, "cannot fetch entries") 200 | require.ElementsMatch(t, expectedEntries, entries, "fetched entries are not matching") 201 | } 202 | -------------------------------------------------------------------------------- /internal/pkg/client/tempo/tempo.go: -------------------------------------------------------------------------------- 1 | package tempo 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "math" 8 | "net/http" 9 | "net/url" 10 | "strconv" 11 | "time" 12 | 13 | "github.com/gabor-boros/minutes/internal/pkg/client" 14 | "github.com/gabor-boros/minutes/internal/pkg/utils" 15 | "github.com/gabor-boros/minutes/internal/pkg/worklog" 16 | ) 17 | 18 | const ( 19 | // PathWorklogCreate is the endpoint used to create new worklogs. 20 | PathWorklogCreate string = "/rest/tempo-timesheets/4/worklogs" 21 | // PathWorklogSearch is the endpoint used to search existing worklogs. 22 | PathWorklogSearch string = "/rest/tempo-timesheets/4/worklogs/search" 23 | ) 24 | 25 | // Issue represents the Jira issue the time logged against. 26 | type Issue struct { 27 | ID int `json:"id"` 28 | Key string `json:"key"` 29 | AccountKey string `json:"accountKey"` 30 | ProjectID int `json:"projectId"` 31 | ProjectKey string `json:"projectKey"` 32 | Summary string `json:"summary"` 33 | } 34 | 35 | // FetchEntry represents the entry fetched from Tempo. 36 | // StartDate must be in the given YYYY-MM-DD format, required by Tempo. 37 | type FetchEntry struct { 38 | ID int `json:"id"` 39 | StartDate time.Time `json:"startDate"` 40 | BillableSeconds int `json:"billableSeconds"` 41 | TimeSpentSeconds int `json:"timeSpentSeconds"` 42 | Comment string `json:"comment"` 43 | WorkerKey string `json:"workerKey"` 44 | Issue Issue `json:"issue"` 45 | } 46 | 47 | // UploadEntry represents the payload to create a new worklog in Tempo. 48 | // Started must be in the given YYYY-MM-DD format, required by Tempo. 49 | type UploadEntry struct { 50 | Comment string `json:"comment,omitempty"` 51 | IncludeNonWorkingDays bool `json:"includeNonWorkingDays,omitempty"` 52 | OriginTaskID string `json:"originTaskId,omitempty"` 53 | Started string `json:"started,omitempty"` 54 | BillableSeconds int `json:"billableSeconds,omitempty"` 55 | TimeSpentSeconds int `json:"timeSpentSeconds,omitempty"` 56 | Worker string `json:"worker,omitempty"` 57 | } 58 | 59 | // SearchParams represents the parameters used to filter Tempo search results. 60 | // From and To must be in the given YYYY-MM-DD format, required by Tempo. 61 | type SearchParams struct { 62 | From string `json:"from"` 63 | To string `json:"to"` 64 | Worker string `json:"worker"` 65 | } 66 | 67 | // ClientOpts is the client specific options, extending client.BaseClientOpts. 68 | type ClientOpts struct { 69 | client.BaseClientOpts 70 | client.BasicAuth 71 | BaseURL string 72 | } 73 | 74 | type tempoClient struct { 75 | *client.BaseClientOpts 76 | *client.HTTPClient 77 | *client.DefaultUploader 78 | authenticator client.Authenticator 79 | } 80 | 81 | func (c *tempoClient) FetchEntries(ctx context.Context, opts *client.FetchOpts) (worklog.Entries, error) { 82 | searchURL, err := c.URL(PathWorklogSearch, map[string]string{}) 83 | if err != nil { 84 | return nil, fmt.Errorf("%v: %v", client.ErrFetchEntries, err) 85 | } 86 | 87 | resp, err := c.Call(ctx, &client.HTTPRequestOpts{ 88 | Method: http.MethodPost, 89 | Url: searchURL, 90 | Auth: c.authenticator, 91 | Timeout: c.Timeout, 92 | Data: &SearchParams{ 93 | From: utils.DateFormatISO8601.Format(opts.Start.Local()), 94 | To: utils.DateFormatISO8601.Format(opts.End.Local()), 95 | Worker: opts.User, 96 | }, 97 | Headers: map[string]string{ 98 | "Content-Type": "application/json", 99 | }, 100 | }) 101 | 102 | if err != nil { 103 | return nil, fmt.Errorf("%v: %v", client.ErrFetchEntries, err) 104 | } 105 | 106 | var fetchedEntries []FetchEntry 107 | if err = json.Unmarshal(resp, &fetchedEntries); err != nil { 108 | return nil, fmt.Errorf("%v: %v", client.ErrFetchEntries, err) 109 | } 110 | 111 | var entries worklog.Entries 112 | for _, entry := range fetchedEntries { 113 | entries = append(entries, worklog.Entry{ 114 | Client: worklog.IDNameField{ 115 | ID: entry.Issue.AccountKey, 116 | Name: entry.Issue.AccountKey, 117 | }, 118 | Project: worklog.IDNameField{ 119 | ID: strconv.Itoa(entry.Issue.ProjectID), 120 | Name: entry.Issue.ProjectKey, 121 | }, 122 | Task: worklog.IDNameField{ 123 | ID: strconv.Itoa(entry.Issue.ID), 124 | Name: entry.Issue.Key, 125 | }, 126 | Summary: entry.Issue.Summary, 127 | Notes: entry.Comment, 128 | Start: entry.StartDate, 129 | BillableDuration: time.Second * time.Duration(entry.BillableSeconds), 130 | UnbillableDuration: time.Second * time.Duration(entry.TimeSpentSeconds-entry.BillableSeconds), 131 | }) 132 | } 133 | 134 | return entries, nil 135 | } 136 | 137 | func (c *tempoClient) UploadEntries(ctx context.Context, entries worklog.Entries, errChan chan error, opts *client.UploadOpts) { 138 | createURL, err := c.URL(PathWorklogCreate, map[string]string{}) 139 | if err != nil { 140 | errChan <- fmt.Errorf("%v: %v", client.ErrUploadEntries, err) 141 | return 142 | } 143 | 144 | for _, groupEntries := range entries.GroupByTask() { 145 | go func(ctx context.Context, entries worklog.Entries, errChan chan error, opts *client.UploadOpts) { 146 | for _, entry := range entries { 147 | billableDuration := entry.BillableDuration 148 | unbillableDuration := entry.UnbillableDuration 149 | totalTimeSpent := billableDuration + unbillableDuration 150 | 151 | if opts.TreatDurationAsBilled { 152 | billableDuration = entry.UnbillableDuration + entry.BillableDuration 153 | unbillableDuration = 0 154 | } 155 | 156 | if opts.RoundToClosestMinute { 157 | billableDuration = time.Second * time.Duration(math.Round(billableDuration.Minutes())*60) 158 | unbillableDuration = time.Second * time.Duration(math.Round(unbillableDuration.Minutes())*60) 159 | totalTimeSpent = billableDuration + unbillableDuration 160 | } 161 | 162 | uploadEntry := &UploadEntry{ 163 | Comment: entry.Summary, 164 | IncludeNonWorkingDays: true, 165 | OriginTaskID: entry.Task.Name, 166 | Started: utils.DateFormatISO8601.Format(entry.Start.Local()), 167 | BillableSeconds: int(billableDuration.Seconds()), 168 | TimeSpentSeconds: int(totalTimeSpent.Seconds()), 169 | Worker: opts.User, 170 | } 171 | 172 | tracker := c.StartTracking(entry, opts.ProgressWriter) 173 | 174 | _, err := c.Call(ctx, &client.HTTPRequestOpts{ 175 | Method: http.MethodPost, 176 | Url: createURL, 177 | Auth: c.authenticator, 178 | Timeout: c.Timeout, 179 | Data: uploadEntry, 180 | Headers: map[string]string{ 181 | "Content-Type": "application/json", 182 | }, 183 | }) 184 | 185 | if err != nil { 186 | err = fmt.Errorf("%v: %+v: %v", client.ErrUploadEntries, uploadEntry, err) 187 | } 188 | 189 | c.StopTracking(tracker, err) 190 | errChan <- err 191 | } 192 | }(ctx, groupEntries, errChan, opts) 193 | } 194 | } 195 | 196 | func newClient(opts *ClientOpts) (*tempoClient, error) { 197 | baseURL, err := url.Parse(opts.BaseURL) 198 | if err != nil { 199 | return nil, err 200 | } 201 | 202 | authenticator, err := client.NewBasicAuth(opts.Username, opts.Password) 203 | if err != nil { 204 | return nil, err 205 | } 206 | 207 | return &tempoClient{ 208 | authenticator: authenticator, 209 | HTTPClient: &client.HTTPClient{BaseURL: baseURL}, 210 | BaseClientOpts: &opts.BaseClientOpts, 211 | }, nil 212 | } 213 | 214 | // NewFetcher returns a new Tempo client for fetching entries. 215 | func NewFetcher(opts *ClientOpts) (client.Fetcher, error) { 216 | return newClient(opts) 217 | } 218 | 219 | // NewUploader returns a new Tempo client for uploading entries. 220 | func NewUploader(opts *ClientOpts) (client.Uploader, error) { 221 | return newClient(opts) 222 | } 223 | -------------------------------------------------------------------------------- /internal/pkg/client/timewarrior/timewarrior.go: -------------------------------------------------------------------------------- 1 | package timewarrior 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "regexp" 8 | "time" 9 | 10 | "github.com/gabor-boros/minutes/internal/pkg/client" 11 | "github.com/gabor-boros/minutes/internal/pkg/utils" 12 | "github.com/gabor-boros/minutes/internal/pkg/worklog" 13 | ) 14 | 15 | // FetchEntry represents the entry exported from Timewarrior. 16 | type FetchEntry struct { 17 | ID int `json:"id"` 18 | Start string `json:"start"` 19 | End string `json:"end"` 20 | Tags []string `json:"tags"` 21 | Annotation string `json:"annotation"` 22 | } 23 | 24 | // ClientOpts is the client specific options, extending client.BaseClientOpts. 25 | // Since Timewarrior is a CLI tool, hence it has no API we could call on HTTP. 26 | // Although client.HTTPClientOpts is part of client.BaseClientOpts, we are 27 | // not using that as part of this integration, instead we are defining the path 28 | // of the executable (Command) and the command arguments used for export 29 | // (CommandArguments). 30 | type ClientOpts struct { 31 | client.BaseClientOpts 32 | client.CLIClient 33 | UnbillableTag string 34 | ClientTagRegex string 35 | ProjectTagRegex string 36 | } 37 | 38 | type timewarriorClient struct { 39 | *client.BaseClientOpts 40 | *client.CLIClient 41 | clientTagRegex *regexp.Regexp 42 | projectTagRegex *regexp.Regexp 43 | unbillableTag string 44 | } 45 | 46 | func (c *timewarriorClient) parseEntry(entry FetchEntry, opts *client.FetchOpts) (worklog.Entries, error) { 47 | var entries worklog.Entries 48 | 49 | startDate, err := time.ParseInLocation(utils.DateFormatRFC3339Compact.String(), entry.Start, time.Local) 50 | if err != nil { 51 | return nil, err 52 | } 53 | 54 | endDate, err := time.ParseInLocation(utils.DateFormatRFC3339Compact.String(), entry.End, time.Local) 55 | if err != nil { 56 | return nil, err 57 | } 58 | 59 | worklogEntry := worklog.Entry{ 60 | Summary: entry.Annotation, 61 | Notes: entry.Annotation, 62 | Start: startDate, 63 | BillableDuration: endDate.Sub(startDate), 64 | UnbillableDuration: 0, 65 | } 66 | 67 | for _, tag := range entry.Tags { 68 | if tag == c.unbillableTag { 69 | worklogEntry.UnbillableDuration = worklogEntry.BillableDuration 70 | worklogEntry.BillableDuration = 0 71 | } else if utils.IsRegexSet(c.clientTagRegex) && c.clientTagRegex.MatchString(tag) { 72 | worklogEntry.Client = worklog.IDNameField{ 73 | ID: tag, 74 | Name: tag, 75 | } 76 | } else if utils.IsRegexSet(c.projectTagRegex) && c.projectTagRegex.MatchString(tag) { 77 | worklogEntry.Project = worklog.IDNameField{ 78 | ID: tag, 79 | Name: tag, 80 | } 81 | } else if utils.IsRegexSet(opts.TagsAsTasksRegex) && opts.TagsAsTasksRegex.MatchString(tag) { 82 | worklogEntry.Task = worklog.IDNameField{ 83 | ID: tag, 84 | Name: tag, 85 | } 86 | } 87 | } 88 | 89 | // If the task was not found in tags, make sure to set it to annotation 90 | if !worklogEntry.Task.IsComplete() { 91 | worklogEntry.Task = worklog.IDNameField{ 92 | ID: entry.Annotation, 93 | Name: entry.Annotation, 94 | } 95 | } 96 | 97 | if utils.IsRegexSet(opts.TagsAsTasksRegex) && len(entry.Tags) > 0 { 98 | var tags []worklog.IDNameField 99 | for _, tag := range entry.Tags { 100 | tags = append(tags, worklog.IDNameField{ 101 | ID: tag, 102 | Name: tag, 103 | }) 104 | } 105 | 106 | splitEntries := worklogEntry.SplitByTagsAsTasks(worklogEntry.Summary, opts.TagsAsTasksRegex, tags) 107 | entries = append(entries, splitEntries...) 108 | } else { 109 | entries = append(entries, worklogEntry) 110 | } 111 | 112 | return entries, nil 113 | } 114 | 115 | func (c *timewarriorClient) executeCommand(ctx context.Context, subcommand string, entries *[]FetchEntry, opts *client.FetchOpts) error { 116 | arguments := []string{subcommand} 117 | 118 | arguments = append( 119 | arguments, 120 | []string{ 121 | "from", utils.DateFormatRFC3339Local.Format(opts.Start), 122 | "to", utils.DateFormatRFC3339Local.Format(opts.End), 123 | }..., 124 | ) 125 | 126 | arguments = append(arguments, c.CommandArguments...) 127 | 128 | out, err := c.Execute(ctx, arguments, &client.CLIExecuteOpts{ 129 | Timeout: c.Timeout, 130 | }) 131 | 132 | if err != nil { 133 | return err 134 | } 135 | 136 | if err = json.Unmarshal(out, &entries); err != nil { 137 | return err 138 | } 139 | 140 | return nil 141 | } 142 | 143 | func (c *timewarriorClient) FetchEntries(ctx context.Context, opts *client.FetchOpts) (worklog.Entries, error) { 144 | var fetchedEntries []FetchEntry 145 | if err := c.executeCommand(ctx, "export", &fetchedEntries, opts); err != nil { 146 | return nil, fmt.Errorf("%v: %v", client.ErrFetchEntries, err) 147 | } 148 | 149 | var entries worklog.Entries 150 | for _, entry := range fetchedEntries { 151 | parsedEntries, err := c.parseEntry(entry, opts) 152 | if err != nil { 153 | return nil, fmt.Errorf("%v: %v", client.ErrFetchEntries, err) 154 | } 155 | 156 | entries = append(entries, parsedEntries...) 157 | } 158 | 159 | return entries, nil 160 | } 161 | 162 | // NewFetcher returns a new Timewarrior client for fetching entries. 163 | func NewFetcher(opts *ClientOpts) (client.Fetcher, error) { 164 | clientTagRegex, err := regexp.Compile(opts.ClientTagRegex) 165 | if err != nil { 166 | return nil, fmt.Errorf("%v: %v", client.ErrFetchEntries, err) 167 | } 168 | 169 | projectTagRegex, err := regexp.Compile(opts.ProjectTagRegex) 170 | if err != nil { 171 | return nil, fmt.Errorf("%v: %v", client.ErrFetchEntries, err) 172 | } 173 | 174 | return &timewarriorClient{ 175 | BaseClientOpts: &opts.BaseClientOpts, 176 | CLIClient: &opts.CLIClient, 177 | unbillableTag: opts.UnbillableTag, 178 | clientTagRegex: clientTagRegex, 179 | projectTagRegex: projectTagRegex, 180 | }, nil 181 | } 182 | -------------------------------------------------------------------------------- /internal/pkg/client/timewarrior/timewarrior_test.go: -------------------------------------------------------------------------------- 1 | package timewarrior_test 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "os/exec" 8 | "regexp" 9 | "strconv" 10 | "testing" 11 | "time" 12 | 13 | "github.com/gabor-boros/minutes/internal/pkg/client" 14 | "github.com/gabor-boros/minutes/internal/pkg/client/timewarrior" 15 | "github.com/gabor-boros/minutes/internal/pkg/utils" 16 | "github.com/gabor-boros/minutes/internal/pkg/worklog" 17 | "github.com/stretchr/testify/require" 18 | ) 19 | 20 | var ( 21 | mockedExitCode int 22 | mockedStdout string 23 | ) 24 | 25 | func mockedExecCommand(_ context.Context, command string, args ...string) *exec.Cmd { 26 | arguments := []string{"-test.run=TestExecCommandHelper", "--", command} 27 | arguments = append(arguments, args...) 28 | cmd := exec.Command(os.Args[0], arguments...) 29 | 30 | cmd.Env = []string{"GO_TEST_HELPER_PROCESS=1", 31 | "STDOUT=" + mockedStdout, 32 | "EXIT_CODE=" + strconv.Itoa(mockedExitCode), 33 | } 34 | 35 | return cmd 36 | } 37 | 38 | // TestExecCommandHelper is a helper test case that will be called by `mockedExecCommand`. 39 | // This workaround is needed to be able to "mock" system calls. 40 | func TestExecCommandHelper(t *testing.T) { 41 | // Not executed by the mocked command function, so return 42 | if os.Getenv("GO_TEST_HELPER_PROCESS") != "1" { 43 | return 44 | } 45 | 46 | _, _ = fmt.Fprint(os.Stdout, os.Getenv("STDOUT")) 47 | exitCode, err := strconv.Atoi(os.Getenv("EXIT_CODE")) 48 | require.NoError(t, err) 49 | 50 | os.Exit(exitCode) 51 | } 52 | 53 | func TestTimewarriorClient_FetchEntries(t *testing.T) { 54 | start, _ := time.ParseInLocation(utils.DateFormatRFC3339Compact.String(), "20211012T054408Z", time.Local) 55 | end, _ := time.ParseInLocation(utils.DateFormatRFC3339Compact.String(), "20211012T054420Z", time.Local) 56 | 57 | mockedExitCode = 0 58 | mockedStdout = `[ 59 | {"id":3,"start":"20211012T054408Z","end":"20211012T054420Z","tags":["TASK-123","project","otherclient"],"annotation":"working on timewarrior integration"}, 60 | {"id":2,"start":"20211012T054408Z","end":"20211012T054420Z","tags":["TASK-123","project","client","unbillable"],"annotation":"working unbilled"}, 61 | {"id":1,"start":"20211012T054408Z","end":"20211012T054420Z","tags":["TASK-123","TASK-456","project","client","unbillable"],"annotation":"working unbilled"} 62 | ]` 63 | 64 | expectedEntries := worklog.Entries{ 65 | { 66 | Client: worklog.IDNameField{ 67 | ID: "otherclient", 68 | Name: "otherclient", 69 | }, 70 | Project: worklog.IDNameField{ 71 | ID: "project", 72 | Name: "project", 73 | }, 74 | Task: worklog.IDNameField{ 75 | ID: "working on timewarrior integration", 76 | Name: "working on timewarrior integration", 77 | }, 78 | Summary: "working on timewarrior integration", 79 | Notes: "working on timewarrior integration", 80 | Start: start, 81 | BillableDuration: end.Sub(start), 82 | UnbillableDuration: 0, 83 | }, 84 | { 85 | Client: worklog.IDNameField{ 86 | ID: "client", 87 | Name: "client", 88 | }, 89 | Project: worklog.IDNameField{ 90 | ID: "project", 91 | Name: "project", 92 | }, 93 | Task: worklog.IDNameField{ 94 | ID: "working unbilled", 95 | Name: "working unbilled", 96 | }, 97 | Summary: "working unbilled", 98 | Notes: "working unbilled", 99 | Start: start, 100 | BillableDuration: 0, 101 | UnbillableDuration: end.Sub(start), 102 | }, 103 | { 104 | Client: worklog.IDNameField{ 105 | ID: "client", 106 | Name: "client", 107 | }, 108 | Project: worklog.IDNameField{ 109 | ID: "project", 110 | Name: "project", 111 | }, 112 | Task: worklog.IDNameField{ 113 | ID: "working unbilled", 114 | Name: "working unbilled", 115 | }, 116 | Summary: "working unbilled", 117 | Notes: "working unbilled", 118 | Start: start, 119 | BillableDuration: 0, 120 | UnbillableDuration: end.Sub(start), 121 | }, 122 | } 123 | 124 | timewarriorClient, err := timewarrior.NewFetcher(&timewarrior.ClientOpts{ 125 | BaseClientOpts: client.BaseClientOpts{ 126 | Timeout: client.DefaultRequestTimeout, 127 | }, 128 | CLIClient: client.CLIClient{ 129 | Command: "timewarrior-command", 130 | CommandArguments: []string{}, 131 | CommandCtxExecutor: mockedExecCommand, 132 | }, 133 | UnbillableTag: "unbillable", 134 | ClientTagRegex: "^(client|otherclient)$", 135 | ProjectTagRegex: "^(project)$", 136 | }) 137 | 138 | require.Nil(t, err) 139 | 140 | entries, err := timewarriorClient.FetchEntries(context.Background(), &client.FetchOpts{ 141 | Start: start, 142 | End: end, 143 | TagsAsTasksRegex: regexp.MustCompile(""), 144 | }) 145 | 146 | require.Nil(t, err, "cannot fetch entries") 147 | require.ElementsMatch(t, expectedEntries, entries, "fetched entries are not matching") 148 | } 149 | 150 | func TestTimewarriorClient_FetchEntries_TagsAsTasksRegex_NoSplit(t *testing.T) { 151 | start, _ := time.ParseInLocation(utils.DateFormatRFC3339Compact.String(), "20211012T054408Z", time.Local) 152 | end, _ := time.ParseInLocation(utils.DateFormatRFC3339Compact.String(), "20211012T054420Z", time.Local) 153 | 154 | mockedExitCode = 0 155 | mockedStdout = `[ 156 | {"id":3,"start":"20211012T054408Z","end":"20211012T054420Z","tags":["TASK-123","project","otherclient"],"annotation":"working on timewarrior integration"}, 157 | {"id":2,"start":"20211012T054408Z","end":"20211012T054420Z","tags":["TASK-123","project","client","unbillable"],"annotation":"working unbilled"}, 158 | {"id":1,"start":"20211012T054408Z","end":"20211012T054420Z","tags":["TASK-456","project","client","unbillable"],"annotation":"working unbilled"} 159 | ]` 160 | 161 | expectedEntries := worklog.Entries{ 162 | { 163 | Client: worklog.IDNameField{ 164 | ID: "otherclient", 165 | Name: "otherclient", 166 | }, 167 | Project: worklog.IDNameField{ 168 | ID: "project", 169 | Name: "project", 170 | }, 171 | Task: worklog.IDNameField{ 172 | ID: "TASK-123", 173 | Name: "TASK-123", 174 | }, 175 | Summary: "working on timewarrior integration", 176 | Notes: "working on timewarrior integration", 177 | Start: start, 178 | BillableDuration: end.Sub(start), 179 | UnbillableDuration: 0, 180 | }, 181 | { 182 | Client: worklog.IDNameField{ 183 | ID: "client", 184 | Name: "client", 185 | }, 186 | Project: worklog.IDNameField{ 187 | ID: "project", 188 | Name: "project", 189 | }, 190 | Task: worklog.IDNameField{ 191 | ID: "TASK-123", 192 | Name: "TASK-123", 193 | }, 194 | Summary: "working unbilled", 195 | Notes: "working unbilled", 196 | Start: start, 197 | BillableDuration: 0, 198 | UnbillableDuration: end.Sub(start), 199 | }, 200 | { 201 | Client: worklog.IDNameField{ 202 | ID: "client", 203 | Name: "client", 204 | }, 205 | Project: worklog.IDNameField{ 206 | ID: "project", 207 | Name: "project", 208 | }, 209 | Task: worklog.IDNameField{ 210 | ID: "TASK-456", 211 | Name: "TASK-456", 212 | }, 213 | Summary: "working unbilled", 214 | Notes: "working unbilled", 215 | Start: start, 216 | BillableDuration: 0, 217 | UnbillableDuration: end.Sub(start), 218 | }, 219 | } 220 | 221 | timewarriorClient, err := timewarrior.NewFetcher(&timewarrior.ClientOpts{ 222 | BaseClientOpts: client.BaseClientOpts{ 223 | Timeout: client.DefaultRequestTimeout, 224 | }, 225 | CLIClient: client.CLIClient{ 226 | Command: "timewarrior-command", 227 | CommandArguments: []string{}, 228 | CommandCtxExecutor: mockedExecCommand, 229 | }, 230 | UnbillableTag: "unbillable", 231 | ClientTagRegex: "^(client|otherclient)$", 232 | ProjectTagRegex: "^(project)$", 233 | }) 234 | 235 | require.Nil(t, err) 236 | 237 | entries, err := timewarriorClient.FetchEntries(context.Background(), &client.FetchOpts{ 238 | Start: start, 239 | End: end, 240 | TagsAsTasksRegex: regexp.MustCompile(`^TASK-\d+$`), 241 | }) 242 | 243 | require.Nil(t, err, "cannot fetch entries") 244 | require.ElementsMatch(t, expectedEntries, entries, "fetched entries are not matching") 245 | } 246 | 247 | func TestTimewarriorClient_FetchEntries_TagsAsTasks(t *testing.T) { 248 | start, _ := time.ParseInLocation(utils.DateFormatRFC3339Compact.String(), "20211012T054408Z", time.Local) 249 | end, _ := time.ParseInLocation(utils.DateFormatRFC3339Compact.String(), "20211012T054420Z", time.Local) 250 | 251 | mockedExitCode = 0 252 | mockedStdout = `[ 253 | {"id":3,"start":"20211012T054408Z","end":"20211012T054420Z","tags":["TASK-123","project","otherclient"],"annotation":"working on timewarrior integration"}, 254 | {"id":2,"start":"20211012T054408Z","end":"20211012T054420Z","tags":["TASK-123","project","client","unbillable"],"annotation":"working unbilled"}, 255 | {"id":1,"start":"20211012T054408Z","end":"20211012T054420Z","tags":["TASK-123","TASK-456","project","client","unbillable"],"annotation":"working unbilled split"} 256 | ]` 257 | 258 | expectedEntries := worklog.Entries{ 259 | { 260 | Client: worklog.IDNameField{ 261 | ID: "otherclient", 262 | Name: "otherclient", 263 | }, 264 | Project: worklog.IDNameField{ 265 | ID: "project", 266 | Name: "project", 267 | }, 268 | Task: worklog.IDNameField{ 269 | ID: "TASK-123", 270 | Name: "TASK-123", 271 | }, 272 | Summary: "working on timewarrior integration", 273 | Notes: "working on timewarrior integration", 274 | Start: start, 275 | BillableDuration: end.Sub(start), 276 | UnbillableDuration: 0, 277 | }, 278 | { 279 | Client: worklog.IDNameField{ 280 | ID: "client", 281 | Name: "client", 282 | }, 283 | Project: worklog.IDNameField{ 284 | ID: "project", 285 | Name: "project", 286 | }, 287 | Task: worklog.IDNameField{ 288 | ID: "TASK-123", 289 | Name: "TASK-123", 290 | }, 291 | Summary: "working unbilled", 292 | Notes: "working unbilled", 293 | Start: start, 294 | BillableDuration: 0, 295 | UnbillableDuration: end.Sub(start), 296 | }, 297 | { 298 | Client: worklog.IDNameField{ 299 | ID: "client", 300 | Name: "client", 301 | }, 302 | Project: worklog.IDNameField{ 303 | ID: "project", 304 | Name: "project", 305 | }, 306 | Task: worklog.IDNameField{ 307 | ID: "TASK-123", 308 | Name: "TASK-123", 309 | }, 310 | Summary: "working unbilled split", 311 | Notes: "working unbilled split", 312 | Start: start, 313 | BillableDuration: 0, 314 | UnbillableDuration: end.Sub(start) / 2, 315 | }, 316 | { 317 | Client: worklog.IDNameField{ 318 | ID: "client", 319 | Name: "client", 320 | }, 321 | Project: worklog.IDNameField{ 322 | ID: "project", 323 | Name: "project", 324 | }, 325 | Task: worklog.IDNameField{ 326 | ID: "TASK-456", 327 | Name: "TASK-456", 328 | }, 329 | Summary: "working unbilled split", 330 | Notes: "working unbilled split", 331 | Start: start, 332 | BillableDuration: 0, 333 | UnbillableDuration: end.Sub(start) / 2, 334 | }, 335 | } 336 | 337 | timewarriorClient, err := timewarrior.NewFetcher(&timewarrior.ClientOpts{ 338 | BaseClientOpts: client.BaseClientOpts{ 339 | Timeout: client.DefaultRequestTimeout, 340 | }, 341 | CLIClient: client.CLIClient{ 342 | Command: "timewarrior-command", 343 | CommandArguments: []string{}, 344 | CommandCtxExecutor: mockedExecCommand, 345 | }, 346 | UnbillableTag: "unbillable", 347 | ClientTagRegex: "^(client|otherclient)$", 348 | ProjectTagRegex: "^(project)$", 349 | }) 350 | 351 | require.Nil(t, err) 352 | 353 | entries, err := timewarriorClient.FetchEntries(context.Background(), &client.FetchOpts{ 354 | Start: start, 355 | End: end, 356 | TagsAsTasksRegex: regexp.MustCompile(`^TASK-\d+$`), 357 | }) 358 | 359 | require.Nil(t, err, "cannot fetch entries") 360 | require.ElementsMatch(t, expectedEntries, entries, "fetched entries are not matching") 361 | } 362 | -------------------------------------------------------------------------------- /internal/pkg/client/toggl/toggl.go: -------------------------------------------------------------------------------- 1 | package toggl 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | "net/url" 9 | "strconv" 10 | "time" 11 | 12 | "github.com/gabor-boros/minutes/internal/pkg/client" 13 | "github.com/gabor-boros/minutes/internal/pkg/utils" 14 | "github.com/gabor-boros/minutes/internal/pkg/worklog" 15 | ) 16 | 17 | const ( 18 | // PathWorklog is the endpoint used to search existing worklogs. 19 | PathWorklog string = "/reports/api/v2/details" 20 | ) 21 | 22 | // FetchEntry represents the entry fetched from Toggl Track. 23 | type FetchEntry struct { 24 | Client string `json:"client"` 25 | Description string `json:"description"` 26 | Duration int `json:"dur"` 27 | IsBillable bool `json:"is_billable"` 28 | Project string `json:"project"` 29 | ProjectID int `json:"pid"` 30 | Start time.Time `json:"start"` 31 | End time.Time `json:"end"` 32 | Tags []string `json:"tags"` 33 | Task string `json:"task"` 34 | TaskID int `json:"tid"` 35 | } 36 | 37 | // FetchResponse represents the response of Toggl Track report APIs. 38 | // The response would have more fields, but those are not relevant for us. 39 | type FetchResponse struct { 40 | TotalCount int `json:"total_count"` 41 | PerPage int `json:"per_page"` 42 | Data []FetchEntry `json:"data"` 43 | } 44 | 45 | // ClientOpts is the client specific options, extending client.BaseClientOpts. 46 | type ClientOpts struct { 47 | client.BaseClientOpts 48 | client.BasicAuth 49 | BaseURL string 50 | Workspace int 51 | } 52 | 53 | type togglClient struct { 54 | *client.BaseClientOpts 55 | *client.HTTPClient 56 | authenticator client.Authenticator 57 | workspace int 58 | } 59 | 60 | func (c *togglClient) parseEntries(rawEntries interface{}, opts *client.FetchOpts) (worklog.Entries, error) { 61 | var entries worklog.Entries 62 | 63 | fetchedEntries, ok := rawEntries.([]FetchEntry) 64 | if !ok { 65 | return nil, fmt.Errorf("%v: %s", client.ErrFetchEntries, "cannot parse returned entries") 66 | } 67 | 68 | for _, fetchedEntry := range fetchedEntries { 69 | billableDuration := time.Millisecond * time.Duration(fetchedEntry.Duration) 70 | unbillableDuration := time.Duration(0) 71 | 72 | if !fetchedEntry.IsBillable { 73 | unbillableDuration = billableDuration 74 | billableDuration = 0 75 | } 76 | 77 | entry := worklog.Entry{ 78 | Client: worklog.IDNameField{ 79 | ID: fetchedEntry.Client, 80 | Name: fetchedEntry.Client, 81 | }, 82 | Project: worklog.IDNameField{ 83 | ID: strconv.Itoa(fetchedEntry.ProjectID), 84 | Name: fetchedEntry.Project, 85 | }, 86 | Task: worklog.IDNameField{ 87 | ID: strconv.Itoa(fetchedEntry.TaskID), 88 | Name: fetchedEntry.Task, 89 | }, 90 | Summary: fetchedEntry.Description, 91 | Notes: fetchedEntry.Description, 92 | Start: fetchedEntry.Start, 93 | BillableDuration: billableDuration, 94 | UnbillableDuration: unbillableDuration, 95 | } 96 | 97 | if utils.IsRegexSet(opts.TagsAsTasksRegex) && len(fetchedEntry.Tags) > 0 { 98 | var tags []worklog.IDNameField 99 | for _, tag := range fetchedEntry.Tags { 100 | tags = append(tags, worklog.IDNameField{ 101 | ID: tag, 102 | Name: tag, 103 | }) 104 | } 105 | 106 | splitEntries := entry.SplitByTagsAsTasks(entry.Summary, opts.TagsAsTasksRegex, tags) 107 | entries = append(entries, splitEntries...) 108 | } else { 109 | entries = append(entries, entry) 110 | } 111 | } 112 | 113 | return entries, nil 114 | } 115 | 116 | func (c *togglClient) fetchEntries(ctx context.Context, reqURL string) (interface{}, *client.PaginatedFetchResponse, error) { 117 | resp, err := c.Call(ctx, &client.HTTPRequestOpts{ 118 | Method: http.MethodGet, 119 | Url: reqURL, 120 | Auth: c.authenticator, 121 | Timeout: c.Timeout, 122 | }) 123 | 124 | if err != nil { 125 | return nil, nil, fmt.Errorf("%v: %v", client.ErrFetchEntries, err) 126 | } 127 | 128 | var fetchResponse FetchResponse 129 | if err = json.Unmarshal(resp, &fetchResponse); err != nil { 130 | return nil, nil, fmt.Errorf("%v: %v", client.ErrFetchEntries, err) 131 | } 132 | 133 | paginatedResponse := &client.PaginatedFetchResponse{ 134 | EntriesPerPage: fetchResponse.PerPage, 135 | TotalEntries: fetchResponse.TotalCount, 136 | } 137 | 138 | return fetchResponse.Data, paginatedResponse, err 139 | } 140 | 141 | func (c *togglClient) FetchEntries(ctx context.Context, opts *client.FetchOpts) (worklog.Entries, error) { 142 | fetchURL, err := c.URL(PathWorklog, map[string]string{ 143 | "since": utils.DateFormatISO8601.Format(opts.Start), 144 | "until": utils.DateFormatISO8601.Format(opts.End), 145 | "user_id": opts.User, 146 | "workspace_id": strconv.Itoa(c.workspace), 147 | "user_agent": "github.com/gabor-boros/minutes", 148 | }) 149 | 150 | if err != nil { 151 | return nil, fmt.Errorf("%v: %v", client.ErrFetchEntries, err) 152 | } 153 | 154 | return c.PaginatedFetch(ctx, &client.PaginatedFetchOpts{ 155 | BaseFetchOpts: opts, 156 | URL: fetchURL, 157 | FetchFunc: c.fetchEntries, 158 | ParseFunc: c.parseEntries, 159 | }) 160 | } 161 | 162 | // NewFetcher returns a new Toggl client for fetching entries. 163 | func NewFetcher(opts *ClientOpts) (client.Fetcher, error) { 164 | baseURL, err := url.Parse(opts.BaseURL) 165 | if err != nil { 166 | return nil, err 167 | } 168 | 169 | authenticator, err := client.NewBasicAuth(opts.Username, opts.Password) 170 | if err != nil { 171 | return nil, err 172 | } 173 | 174 | return &togglClient{ 175 | authenticator: authenticator, 176 | HTTPClient: &client.HTTPClient{ 177 | BaseURL: baseURL, 178 | }, 179 | BaseClientOpts: &opts.BaseClientOpts, 180 | workspace: opts.Workspace, 181 | }, nil 182 | } 183 | -------------------------------------------------------------------------------- /internal/pkg/client/toggl/toggl_test.go: -------------------------------------------------------------------------------- 1 | package toggl_test 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "net/http" 7 | "net/http/httptest" 8 | "net/url" 9 | "regexp" 10 | "strconv" 11 | "testing" 12 | "time" 13 | 14 | "github.com/gabor-boros/minutes/internal/pkg/client" 15 | "github.com/gabor-boros/minutes/internal/pkg/client/toggl" 16 | "github.com/gabor-boros/minutes/internal/pkg/utils" 17 | "github.com/gabor-boros/minutes/internal/pkg/worklog" 18 | "github.com/stretchr/testify/require" 19 | ) 20 | 21 | type mockServerOpts struct { 22 | Path string 23 | QueryParams url.Values 24 | Method string 25 | StatusCode int 26 | Username string 27 | Password string 28 | ResponseData *toggl.FetchResponse 29 | } 30 | 31 | func mockServer(t *testing.T, e *mockServerOpts) *httptest.Server { 32 | return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 33 | require.Equal(t, e.Method, r.Method, "API call methods are not matching") 34 | require.Equal(t, e.Path, r.URL.Path, "API call URLs are not matching") 35 | require.Equal(t, e.QueryParams, r.URL.Query()) 36 | 37 | if e.Username != "" && e.Password != "" { 38 | username, password, _ := r.BasicAuth() 39 | require.Equal(t, e.Username, username, "API call basic auth username mismatch") 40 | require.Equal(t, e.Password, password, "API call basic auth password mismatch") 41 | } 42 | 43 | if e.ResponseData != nil { 44 | err := json.NewEncoder(w).Encode(e.ResponseData) 45 | require.Nil(t, err, "cannot encode response data") 46 | } 47 | 48 | w.WriteHeader(e.StatusCode) 49 | })) 50 | } 51 | 52 | func newMockServer(t *testing.T, opts *mockServerOpts) *httptest.Server { 53 | mockServer := mockServer(t, opts) 54 | require.NotNil(t, mockServer, "cannot create mock server") 55 | return mockServer 56 | } 57 | 58 | func TestTogglClient_FetchEntries(t *testing.T) { 59 | start := time.Date(2021, 10, 2, 0, 0, 0, 0, time.UTC) 60 | end := time.Date(2021, 10, 2, 23, 59, 59, 0, time.UTC) 61 | 62 | clientUsername := "token-of-the-day" 63 | clientPassword := "api_token" 64 | 65 | expectedEntries := worklog.Entries{ 66 | { 67 | Client: worklog.IDNameField{ 68 | ID: "My Awesome Company", 69 | Name: "My Awesome Company", 70 | }, 71 | Project: worklog.IDNameField{ 72 | ID: strconv.Itoa(456), 73 | Name: "MARVEL", 74 | }, 75 | Task: worklog.IDNameField{ 76 | ID: strconv.Itoa(789), 77 | Name: "CPT-2014", 78 | }, 79 | Summary: "I met with The Winter Soldier", 80 | Notes: "I met with The Winter Soldier", 81 | Start: start, 82 | BillableDuration: time.Second * 3600, 83 | UnbillableDuration: 0, 84 | }, 85 | { 86 | Client: worklog.IDNameField{ 87 | ID: "My Awesome Company", 88 | Name: "My Awesome Company", 89 | }, 90 | Project: worklog.IDNameField{ 91 | ID: strconv.Itoa(456), 92 | Name: "MARVEL", 93 | }, 94 | Task: worklog.IDNameField{ 95 | ID: strconv.Itoa(789), 96 | Name: "CPT-2014", 97 | }, 98 | Summary: "I helped him to get back on track", 99 | Notes: "I helped him to get back on track", 100 | Start: start, 101 | BillableDuration: 0, 102 | UnbillableDuration: time.Second * 3600, 103 | }, 104 | } 105 | 106 | mockServer := newMockServer(t, &mockServerOpts{ 107 | Path: toggl.PathWorklog, 108 | QueryParams: url.Values{ 109 | "page": {"1"}, 110 | "per_page": {"50"}, 111 | "since": {utils.DateFormatISO8601.Format(start)}, 112 | "until": {utils.DateFormatISO8601.Format(end)}, 113 | "user_id": {"987654321"}, 114 | "workspace_id": {"123456789"}, 115 | "user_agent": {"github.com/gabor-boros/minutes"}, 116 | }, 117 | Method: http.MethodGet, 118 | StatusCode: http.StatusOK, 119 | Username: clientUsername, 120 | Password: clientPassword, 121 | ResponseData: &toggl.FetchResponse{ 122 | TotalCount: 2, 123 | PerPage: 50, 124 | Data: []toggl.FetchEntry{ 125 | { 126 | Client: "My Awesome Company", 127 | Description: "I met with The Winter Soldier", 128 | Duration: 3600000, 129 | IsBillable: true, 130 | Project: "MARVEL", 131 | ProjectID: 456, 132 | Start: start, 133 | End: start.Add(3600000), 134 | Tags: nil, 135 | Task: "CPT-2014", 136 | TaskID: 789, 137 | }, 138 | { 139 | Client: "My Awesome Company", 140 | Description: "I helped him to get back on track", 141 | Duration: 3600000, 142 | IsBillable: false, 143 | Project: "MARVEL", 144 | ProjectID: 456, 145 | Start: start, 146 | End: start.Add(3600000), 147 | Tags: nil, 148 | Task: "CPT-2014", 149 | TaskID: 789, 150 | }, 151 | }, 152 | }, 153 | }) 154 | defer mockServer.Close() 155 | 156 | togglClient, err := toggl.NewFetcher(&toggl.ClientOpts{ 157 | BaseClientOpts: client.BaseClientOpts{ 158 | Timeout: client.DefaultRequestTimeout, 159 | }, 160 | BasicAuth: client.BasicAuth{ 161 | Username: clientUsername, 162 | Password: clientPassword, 163 | }, 164 | BaseURL: mockServer.URL, 165 | Workspace: 123456789, 166 | }) 167 | require.Nil(t, err) 168 | 169 | entries, err := togglClient.FetchEntries(context.Background(), &client.FetchOpts{ 170 | User: "987654321", 171 | Start: start, 172 | End: end, 173 | }) 174 | 175 | require.Nil(t, err, "cannot fetch entries") 176 | require.ElementsMatch(t, expectedEntries, entries, "fetched entries are not matching") 177 | } 178 | 179 | func TestTogglClient_FetchEntries_TagsAsTasks(t *testing.T) { 180 | start := time.Date(2021, 10, 2, 0, 0, 0, 0, time.UTC) 181 | end := time.Date(2021, 10, 2, 23, 59, 59, 0, time.UTC) 182 | 183 | clientUsername := "token-of-the-day" 184 | clientPassword := "api_token" 185 | 186 | expectedEntries := worklog.Entries{ 187 | { 188 | Client: worklog.IDNameField{ 189 | ID: "My Awesome Company", 190 | Name: "My Awesome Company", 191 | }, 192 | Project: worklog.IDNameField{ 193 | ID: strconv.Itoa(456), 194 | Name: "MARVEL", 195 | }, 196 | Task: worklog.IDNameField{ 197 | ID: "CPT-2014", 198 | Name: "CPT-2014", 199 | }, 200 | Summary: "I met with The Winter Soldier", 201 | Notes: "I met with The Winter Soldier", 202 | Start: start, 203 | BillableDuration: time.Second * 3600, 204 | UnbillableDuration: 0, 205 | }, 206 | { 207 | Client: worklog.IDNameField{ 208 | ID: "My Awesome Company", 209 | Name: "My Awesome Company", 210 | }, 211 | Project: worklog.IDNameField{ 212 | ID: strconv.Itoa(456), 213 | Name: "MARVEL", 214 | }, 215 | Task: worklog.IDNameField{ 216 | ID: "CPT-2014", 217 | Name: "CPT-2014", 218 | }, 219 | Summary: "I helped him to get back on track", 220 | Notes: "I helped him to get back on track", 221 | Start: start, 222 | BillableDuration: 0, 223 | UnbillableDuration: time.Second * 1800, 224 | }, 225 | { 226 | Client: worklog.IDNameField{ 227 | ID: "My Awesome Company", 228 | Name: "My Awesome Company", 229 | }, 230 | Project: worklog.IDNameField{ 231 | ID: strconv.Itoa(456), 232 | Name: "MARVEL", 233 | }, 234 | Task: worklog.IDNameField{ 235 | ID: "CPT-MISC", 236 | Name: "CPT-MISC", 237 | }, 238 | Summary: "I helped him to get back on track", 239 | Notes: "I helped him to get back on track", 240 | Start: start, 241 | BillableDuration: 0, 242 | UnbillableDuration: time.Second * 1800, 243 | }, 244 | } 245 | 246 | mockServer := newMockServer(t, &mockServerOpts{ 247 | Path: toggl.PathWorklog, 248 | QueryParams: url.Values{ 249 | "page": {"1"}, 250 | "per_page": {"50"}, 251 | "since": {utils.DateFormatISO8601.Format(start)}, 252 | "until": {utils.DateFormatISO8601.Format(end)}, 253 | "user_id": {"987654321"}, 254 | "workspace_id": {"123456789"}, 255 | "user_agent": {"github.com/gabor-boros/minutes"}, 256 | }, 257 | Method: http.MethodGet, 258 | StatusCode: http.StatusOK, 259 | Username: clientUsername, 260 | Password: clientPassword, 261 | ResponseData: &toggl.FetchResponse{ 262 | TotalCount: 2, 263 | PerPage: 50, 264 | Data: []toggl.FetchEntry{ 265 | { 266 | Client: "My Awesome Company", 267 | Description: "I met with The Winter Soldier", 268 | Duration: 3600000, 269 | IsBillable: true, 270 | Project: "MARVEL", 271 | ProjectID: 456, 272 | Start: start, 273 | End: start.Add(3600000), 274 | Tags: []string{ 275 | "CPT-2014", 276 | }, 277 | }, 278 | { 279 | Client: "My Awesome Company", 280 | Description: "I helped him to get back on track", 281 | Duration: 3600000, 282 | IsBillable: false, 283 | Project: "MARVEL", 284 | ProjectID: 456, 285 | Start: start, 286 | End: start.Add(3600000), 287 | Tags: []string{ 288 | "CPT-2014", 289 | "CPT-MISC", 290 | "IGNORED", 291 | }, 292 | }, 293 | }, 294 | }, 295 | }) 296 | defer mockServer.Close() 297 | 298 | togglClient, err := toggl.NewFetcher(&toggl.ClientOpts{ 299 | BaseClientOpts: client.BaseClientOpts{ 300 | Timeout: client.DefaultRequestTimeout, 301 | }, 302 | BasicAuth: client.BasicAuth{ 303 | Username: clientUsername, 304 | Password: clientPassword, 305 | }, 306 | BaseURL: mockServer.URL, 307 | Workspace: 123456789, 308 | }) 309 | require.Nil(t, err) 310 | 311 | entries, err := togglClient.FetchEntries(context.Background(), &client.FetchOpts{ 312 | User: "987654321", 313 | Start: start, 314 | End: end, 315 | TagsAsTasksRegex: regexp.MustCompile(`^CPT-\w+$`), 316 | }) 317 | 318 | require.Nil(t, err, "cannot fetch entries") 319 | require.ElementsMatch(t, expectedEntries, entries, "fetched entries are not matching") 320 | } 321 | -------------------------------------------------------------------------------- /internal/pkg/client/uploader.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | 7 | "github.com/gabor-boros/minutes/internal/pkg/worklog" 8 | "github.com/jedib0t/go-pretty/v6/progress" 9 | ) 10 | 11 | var ( 12 | // ErrUploadEntries wraps the error when upload failed. 13 | ErrUploadEntries = errors.New("failed to upload entries") 14 | ) 15 | 16 | // UploadOpts specifies the only options for the Uploader. In contrast to the 17 | // BaseClientOpts, these options shall not be extended or overridden. 18 | type UploadOpts struct { 19 | // RoundToClosestMinute indicates to round the billed and unbilled duration 20 | // separately to the closest minute. 21 | // If the elapsed time is 30 seconds or more, the closest minute is the 22 | // next minute, otherwise the previous one. In case the previous minute is 23 | // 0 (zero), then 0 (zero) will be used for the billed and/or unbilled 24 | // duration. 25 | RoundToClosestMinute bool 26 | // TreatDurationAsBilled indicates to use every time spent as billed. 27 | TreatDurationAsBilled bool 28 | // CreateMissingResources indicates the need of resource creation if the 29 | // resource is missing. 30 | // In the case of some Uploader, the resources must exist to be able to 31 | // use them by their ID or name. 32 | CreateMissingResources bool 33 | // User represents the user in which name the time log will be uploaded. 34 | User string 35 | // ProgressWriter represents a writer that tracks the upload progress. 36 | // In case the ProgressWriter is nil, that means the upload progress should 37 | // not be tracked, hence, that's not an error. 38 | ProgressWriter progress.Writer 39 | } 40 | 41 | // Uploader specifies the functions used to upload worklog entries. 42 | type Uploader interface { 43 | // UploadEntries to a given target. 44 | // If the upload resulted in an error, the upload will stop and an error 45 | // will return. 46 | UploadEntries(ctx context.Context, entries worklog.Entries, errChan chan error, opts *UploadOpts) 47 | } 48 | 49 | // DefaultUploader defines helper function to make entry upload easier 50 | type DefaultUploader struct{} 51 | 52 | // StartTracking creates a progress tracker, appends to the progress writer, then 53 | // returns the appended writer for later use. 54 | func (u *DefaultUploader) StartTracking(entry worklog.Entry, writer progress.Writer) *progress.Tracker { 55 | var tracker *progress.Tracker 56 | 57 | if writer != nil { 58 | tracker = &progress.Tracker{ 59 | Message: entry.Summary, 60 | Total: 1, 61 | Units: progress.UnitsDefault, 62 | } 63 | 64 | writer.AppendTracker(tracker) 65 | } 66 | 67 | return tracker 68 | } 69 | 70 | func (u *DefaultUploader) StopTracking(tracker *progress.Tracker, err error) { 71 | if tracker == nil { 72 | return 73 | } 74 | 75 | if err == nil { 76 | tracker.MarkAsDone() 77 | } else { 78 | tracker.MarkAsErrored() 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /internal/pkg/client/uploader_test.go: -------------------------------------------------------------------------------- 1 | package client_test 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | "time" 7 | 8 | "github.com/gabor-boros/minutes/internal/pkg/client" 9 | "github.com/gabor-boros/minutes/internal/pkg/worklog" 10 | "github.com/jedib0t/go-pretty/v6/progress" 11 | "github.com/stretchr/testify/require" 12 | ) 13 | 14 | func getTestEntry() worklog.Entry { 15 | start := time.Date(2021, 10, 2, 5, 0, 0, 0, time.UTC) 16 | end := start.Add(time.Hour * 2) 17 | 18 | return worklog.Entry{ 19 | Client: worklog.IDNameField{ 20 | ID: "client-id", 21 | Name: "My Awesome Company", 22 | }, 23 | Project: worklog.IDNameField{ 24 | ID: "project-id", 25 | Name: "Internal projects", 26 | }, 27 | Task: worklog.IDNameField{ 28 | ID: "task-id", 29 | Name: "TASK-0123", 30 | }, 31 | Summary: "Write worklog transfer CLI tool", 32 | Notes: "It is a lot easier than expected", 33 | Start: start, 34 | BillableDuration: end.Sub(start), 35 | UnbillableDuration: 0, 36 | } 37 | } 38 | 39 | func TestDefaultUploader_StartTracking(t *testing.T) { 40 | entry := getTestEntry() 41 | progressWriter := progress.NewWriter() 42 | 43 | uploader := client.DefaultUploader{} 44 | tracker := uploader.StartTracking(entry, progressWriter) 45 | 46 | require.NotNil(t, tracker) 47 | } 48 | 49 | func TestDefaultUploader_StartTracking_NoProgressWriter(t *testing.T) { 50 | entry := getTestEntry() 51 | 52 | uploader := client.DefaultUploader{} 53 | tracker := uploader.StartTracking(entry, nil) 54 | 55 | require.Nil(t, tracker) 56 | } 57 | 58 | func TestDefaultUploader_StopTracking_Success(t *testing.T) { 59 | entry := getTestEntry() 60 | progressWriter := progress.NewWriter() 61 | 62 | uploader := client.DefaultUploader{} 63 | 64 | tracker := uploader.StartTracking(entry, progressWriter) 65 | require.NotNil(t, tracker) 66 | 67 | uploader.StopTracking(tracker, nil) 68 | require.True(t, tracker.IsDone()) 69 | require.False(t, tracker.IsErrored()) 70 | } 71 | 72 | func TestDefaultUploader_StopTracking_Failure(t *testing.T) { 73 | entry := getTestEntry() 74 | progressWriter := progress.NewWriter() 75 | 76 | uploader := client.DefaultUploader{} 77 | 78 | tracker := uploader.StartTracking(entry, progressWriter) 79 | require.NotNil(t, tracker) 80 | 81 | uploader.StopTracking(tracker, errors.New("some error")) 82 | require.True(t, tracker.IsDone()) 83 | require.True(t, tracker.IsErrored()) 84 | } 85 | 86 | func TestDefaultUploader_StopTracking_NoTracker(t *testing.T) { 87 | entry := getTestEntry() 88 | 89 | uploader := client.DefaultUploader{} 90 | 91 | tracker := uploader.StartTracking(entry, nil) 92 | require.Nil(t, tracker) 93 | 94 | uploader.StopTracking(tracker, nil) 95 | } 96 | -------------------------------------------------------------------------------- /internal/pkg/utils/regex.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "regexp" 5 | ) 6 | 7 | // IsRegexSet returns true if the regex is not nil nor an empty string, 8 | // otherwise, it returns false. 9 | func IsRegexSet(r *regexp.Regexp) bool { 10 | return r != nil && r.String() != "" 11 | } 12 | -------------------------------------------------------------------------------- /internal/pkg/utils/regex_test.go: -------------------------------------------------------------------------------- 1 | package utils_test 2 | 3 | import ( 4 | "regexp" 5 | "testing" 6 | 7 | "github.com/gabor-boros/minutes/internal/pkg/utils" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestIsRegexSet(t *testing.T) { 12 | re := regexp.MustCompile("^$") 13 | require.True(t, utils.IsRegexSet(re)) 14 | } 15 | 16 | func TestIsRegexSet_EmptyString(t *testing.T) { 17 | re := regexp.MustCompile("") 18 | require.False(t, utils.IsRegexSet(re)) 19 | } 20 | 21 | func TestIsRegexSet_NilPointer(t *testing.T) { 22 | require.False(t, utils.IsRegexSet(nil)) 23 | } 24 | -------------------------------------------------------------------------------- /internal/pkg/utils/time.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import "time" 4 | 5 | // DateFormat is the enumeration of available date formats, used by clients. 6 | // Although the builtin time package contains several formatting options, some 7 | // clients are using nonsense date time formats. 8 | type DateFormat int 9 | 10 | const ( 11 | // DateFormatISO8601 represents the ISO 8601 date format. 12 | DateFormatISO8601 DateFormat = iota 13 | // DateFormatRFC3339UTC is similar to RFC3339, but has no offset, in UTC. 14 | DateFormatRFC3339UTC 15 | // DateFormatRFC3339Compact is similar to RFC3339, but has no separation. 16 | // This is not a standard date time format, it is used by Timewarrior. 17 | DateFormatRFC3339Compact 18 | // DateFormatRFC3339Local is similar to RFC3339, but lacks timezone info. 19 | // This is not a standard date time format, it is used by Timewarrior. 20 | DateFormatRFC3339Local 21 | ) 22 | 23 | // String returns the string representation of the format. 24 | func (d DateFormat) String() string { 25 | return []string{ 26 | "2006-01-02", // DateFormatISO8601 27 | "2006-01-02T15:04:05Z", // DateFormatRFC3339UTC 28 | "20060102T150405Z", // DateFormatRFC3339Compact 29 | "2006-01-02T15:04:05", // DateFormatRFC3339Local 30 | }[d] 31 | } 32 | 33 | // Format returns the formatted version of the given time. 34 | func (d DateFormat) Format(t time.Time) string { 35 | return t.Format(d.String()) 36 | } 37 | 38 | // Parse the given string with the specified layout. 39 | func (d DateFormat) Parse(s string) (time.Time, error) { 40 | return time.Parse(d.String(), s) 41 | } 42 | -------------------------------------------------------------------------------- /internal/pkg/worklog/entry.go: -------------------------------------------------------------------------------- 1 | package worklog 2 | 3 | import ( 4 | "fmt" 5 | "math" 6 | "regexp" 7 | "strconv" 8 | "time" 9 | ) 10 | 11 | // IDNameField stands for every field that has an ID and Name. 12 | type IDNameField struct { 13 | ID string `json:"id"` 14 | Name string `json:"name"` 15 | } 16 | 17 | // IsComplete indicates if the field has both ID and Name filled. 18 | // In case both fields are filled, it returns true, otherwise, false. 19 | func (f IDNameField) IsComplete() bool { 20 | return f.ID != "" && f.Name != "" 21 | } 22 | 23 | // IntIDNameField stands for every field that has an int ID and string Name. 24 | // This field struct is a helper struct that should be avoided in the code, 25 | // but should serve a good use in client implementation. The field has a 26 | // method ConvertToIDNameField to convert itself into an IDNameField. 27 | type IntIDNameField struct { 28 | ID int `json:"id"` 29 | Name string `json:"name"` 30 | } 31 | 32 | // ConvertToIDNameField creates an IDName field from itself. 33 | // ConvertToIDNameField should be called when it leaves client context. 34 | func (f *IntIDNameField) ConvertToIDNameField() IDNameField { 35 | return IDNameField{ 36 | ID: strconv.Itoa(f.ID), 37 | Name: f.Name, 38 | } 39 | } 40 | 41 | // Entries defines a collection of entries. 42 | type Entries []Entry 43 | 44 | // GroupByTask groups the entries by task IDs and returns the grouped entries. 45 | func (e *Entries) GroupByTask() map[string]Entries { 46 | groups := make(map[string]Entries) 47 | 48 | for _, entry := range *e { 49 | key := entry.Task.ID 50 | entries := groups[key] 51 | groups[key] = append(entries, entry) 52 | } 53 | 54 | return groups 55 | } 56 | 57 | // Entry represents the worklog entry and contains all the necessary data. 58 | type Entry struct { 59 | Client IDNameField 60 | Project IDNameField 61 | Task IDNameField 62 | Summary string 63 | Notes string 64 | Start time.Time 65 | BillableDuration time.Duration 66 | UnbillableDuration time.Duration 67 | } 68 | 69 | // Key returns a unique, per entry key used for grouping similar entries. 70 | func (e *Entry) Key() string { 71 | return fmt.Sprintf("%s:%s:%s:%s", e.Project.Name, e.Task.Name, e.Summary, e.Start.Format("2006-01-02")) 72 | } 73 | 74 | // IsComplete indicates if the entry has all the necessary fields filled. 75 | // If all the necessary fields are complete it returns true, otherwise, false. 76 | func (e *Entry) IsComplete() bool { 77 | hasClient := e.Client.IsComplete() 78 | hasProject := e.Project.IsComplete() 79 | hasTask := e.Task.IsComplete() 80 | 81 | isMetadataFilled := hasProject && hasClient && hasTask && e.Summary != "" 82 | isTimeFilled := !e.Start.IsZero() && (e.BillableDuration.Seconds() > 0 || e.UnbillableDuration.Seconds() > 0) 83 | 84 | return isMetadataFilled && isTimeFilled 85 | } 86 | 87 | // SplitDuration splits the billable and unbillable duration to N parts. 88 | func (e *Entry) SplitDuration(parts int) (splitBillableDuration time.Duration, splitUnbillableDuration time.Duration) { 89 | splitBillableDuration = time.Duration(math.Round(float64(e.BillableDuration.Nanoseconds()) / float64(parts))) 90 | splitUnbillableDuration = time.Duration(math.Round(float64(e.UnbillableDuration.Nanoseconds()) / float64(parts))) 91 | return splitBillableDuration, splitUnbillableDuration 92 | } 93 | 94 | // SplitByTagsAsTasks splits the entry into pieces treating tags as tasks. 95 | // Not matching tags won't be treated as a new entry should be created, 96 | // therefore that tag will be skipped and the returned entries will lack that. 97 | // If no tags are provided, the original entry will be returned as the only item 98 | // of the `Entries` list. 99 | func (e *Entry) SplitByTagsAsTasks(summary string, regex *regexp.Regexp, tags []IDNameField) Entries { 100 | if len(tags) == 0 { 101 | return Entries{*e} 102 | } 103 | 104 | var tasks []IDNameField 105 | for _, tag := range tags { 106 | if taskName := regex.FindString(tag.Name); taskName != "" { 107 | tasks = append(tasks, tag) 108 | } 109 | } 110 | 111 | var entries Entries 112 | totalTasks := len(tasks) 113 | 114 | for _, task := range tasks { 115 | splitBillable, splitUnbillable := e.SplitDuration(totalTasks) 116 | 117 | entries = append(entries, Entry{ 118 | Client: e.Client, 119 | Project: e.Project, 120 | Task: task, 121 | Summary: summary, 122 | Notes: e.Notes, 123 | Start: e.Start, 124 | BillableDuration: splitBillable, 125 | UnbillableDuration: splitUnbillable, 126 | }) 127 | } 128 | 129 | return entries 130 | } 131 | -------------------------------------------------------------------------------- /internal/pkg/worklog/entry_test.go: -------------------------------------------------------------------------------- 1 | package worklog_test 2 | 3 | import ( 4 | "regexp" 5 | "testing" 6 | "time" 7 | 8 | "github.com/stretchr/testify/require" 9 | 10 | "github.com/gabor-boros/minutes/internal/pkg/worklog" 11 | "github.com/stretchr/testify/assert" 12 | ) 13 | 14 | func getCompleteTestEntry() worklog.Entry { 15 | start := time.Date(2021, 10, 2, 5, 0, 0, 0, time.UTC) 16 | end := start.Add(time.Hour * 2) 17 | 18 | return worklog.Entry{ 19 | Client: worklog.IDNameField{ 20 | ID: "client-id", 21 | Name: "My Awesome Company", 22 | }, 23 | Project: worklog.IDNameField{ 24 | ID: "project-id", 25 | Name: "Internal projects", 26 | }, 27 | Task: worklog.IDNameField{ 28 | ID: "task-id", 29 | Name: "TASK-0123", 30 | }, 31 | Summary: "Write worklog transfer CLI tool", 32 | Notes: "It is a lot easier than expected", 33 | Start: start, 34 | BillableDuration: end.Sub(start), 35 | UnbillableDuration: 0, 36 | } 37 | } 38 | 39 | func getIncompleteTestEntry() worklog.Entry { 40 | entry := getCompleteTestEntry() 41 | entry.Task = worklog.IDNameField{} 42 | return entry 43 | } 44 | 45 | func TestIntIDNameField_ConvertToIDNameField(t *testing.T) { 46 | field := worklog.IntIDNameField{ 47 | ID: 1234, 48 | Name: "Test", 49 | } 50 | 51 | expectedField := worklog.IDNameField{ 52 | ID: "1234", 53 | Name: "Test", 54 | } 55 | 56 | require.Equal(t, field.ConvertToIDNameField(), expectedField) 57 | } 58 | 59 | func TestIDNameFieldIsComplete(t *testing.T) { 60 | var field worklog.IDNameField 61 | 62 | assert.False(t, field.IsComplete()) 63 | 64 | field = worklog.IDNameField{ 65 | ID: "101", 66 | } 67 | assert.False(t, field.IsComplete()) 68 | 69 | field = worklog.IDNameField{ 70 | ID: "101", 71 | Name: "MARVEL-101", 72 | } 73 | assert.True(t, field.IsComplete()) 74 | } 75 | 76 | func TestEntries_GroupByTask(t *testing.T) { 77 | entries := worklog.Entries{ 78 | getCompleteTestEntry(), 79 | getCompleteTestEntry(), 80 | getCompleteTestEntry(), 81 | } 82 | 83 | groups := entries.GroupByTask() 84 | 85 | assert.Equal(t, 1, len(groups)) 86 | } 87 | 88 | func TestEntryKey(t *testing.T) { 89 | entry := getCompleteTestEntry() 90 | assert.Equal(t, "Internal projects:TASK-0123:Write worklog transfer CLI tool:2021-10-02", entry.Key()) 91 | } 92 | 93 | func TestEntryIsComplete(t *testing.T) { 94 | entry := getCompleteTestEntry() 95 | assert.True(t, entry.IsComplete()) 96 | } 97 | 98 | func TestEntryIsCompleteIncomplete(t *testing.T) { 99 | var entry worklog.Entry 100 | 101 | entry = getCompleteTestEntry() 102 | entry.Client = worklog.IDNameField{} 103 | assert.False(t, entry.IsComplete()) 104 | 105 | entry = getCompleteTestEntry() 106 | entry.Project = worklog.IDNameField{} 107 | assert.False(t, entry.IsComplete()) 108 | 109 | entry = getCompleteTestEntry() 110 | entry.Task = worklog.IDNameField{} 111 | assert.False(t, entry.IsComplete()) 112 | 113 | entry = getCompleteTestEntry() 114 | entry.Summary = "" 115 | assert.False(t, entry.IsComplete()) 116 | 117 | entry = getCompleteTestEntry() 118 | entry.Start = time.Time{} 119 | assert.False(t, entry.IsComplete()) 120 | 121 | entry = getCompleteTestEntry() 122 | entry.BillableDuration = 0 123 | entry.UnbillableDuration = 0 124 | assert.False(t, entry.IsComplete()) 125 | } 126 | 127 | func TestEntry_SplitDuration(t *testing.T) { 128 | var splitBillable time.Duration 129 | var splitUnbillable time.Duration 130 | entry := getCompleteTestEntry() 131 | 132 | splitBillable, splitUnbillable = entry.SplitDuration(1) 133 | assert.Equal(t, entry.BillableDuration, splitBillable) 134 | assert.Equal(t, entry.UnbillableDuration, splitUnbillable) 135 | 136 | entry.UnbillableDuration = time.Hour * 2 137 | splitBillable, splitUnbillable = entry.SplitDuration(2) 138 | assert.Equal(t, time.Hour*1, splitBillable) 139 | assert.Equal(t, time.Hour*1, splitUnbillable) 140 | } 141 | 142 | func TestEntry_SplitByTag(t *testing.T) { 143 | entry := getCompleteTestEntry() 144 | 145 | regex, err := regexp.Compile(`^TASK-\d+$`) 146 | require.Nil(t, err) 147 | 148 | expectedEntries := worklog.Entries{ 149 | { 150 | Client: entry.Client, 151 | Project: entry.Project, 152 | Task: worklog.IDNameField{ 153 | ID: "123", 154 | Name: "TASK-123", 155 | }, 156 | Summary: "test summary", 157 | Notes: entry.Notes, 158 | Start: entry.Start, 159 | BillableDuration: entry.BillableDuration / 2, 160 | UnbillableDuration: entry.UnbillableDuration / 2, 161 | }, 162 | { 163 | Client: entry.Client, 164 | Project: entry.Project, 165 | Task: worklog.IDNameField{ 166 | ID: "789", 167 | Name: "TASK-789", 168 | }, 169 | Summary: "test summary", 170 | Notes: entry.Notes, 171 | Start: entry.Start, 172 | BillableDuration: entry.BillableDuration / 2, 173 | UnbillableDuration: entry.UnbillableDuration / 2, 174 | }, 175 | } 176 | 177 | entries := entry.SplitByTagsAsTasks("test summary", regex, []worklog.IDNameField{ 178 | { 179 | ID: "123", 180 | Name: "TASK-123", 181 | }, 182 | { 183 | ID: "456", 184 | Name: "NO-MATCH", 185 | }, 186 | { 187 | ID: "789", 188 | Name: "TASK-789", 189 | }, 190 | }) 191 | 192 | assert.ElementsMatch(t, expectedEntries, entries) 193 | } 194 | -------------------------------------------------------------------------------- /internal/pkg/worklog/worklog.go: -------------------------------------------------------------------------------- 1 | package worklog 2 | 3 | import "regexp" 4 | 5 | // FilterOpts represents the worklog creation filtering options. 6 | // When filtering options are set, the entries are matching the regex will be 7 | // part of the worklog, and the rest of them will be dropped. The filtering 8 | // could be part of the fetching process, though that would be less flexible as 9 | // some APIs are not allowing filtering. Also, this way, we can filter results 10 | // using regex. 11 | type FilterOpts struct { 12 | Client *regexp.Regexp 13 | Project *regexp.Regexp 14 | } 15 | 16 | // Worklog is the collection of multiple Entries. 17 | type Worklog struct { 18 | completeEntries Entries 19 | incompleteEntries Entries 20 | } 21 | 22 | // CompleteEntries returns those entries which necessary fields were filled. 23 | func (w *Worklog) CompleteEntries() Entries { 24 | return w.completeEntries 25 | } 26 | 27 | // IncompleteEntries is the opposite of CompleteEntries. 28 | func (w *Worklog) IncompleteEntries() Entries { 29 | return w.incompleteEntries 30 | } 31 | 32 | // isEntryMatching returns true if the entry matching the filter options. 33 | func isEntryMatching(entry Entry, opts *FilterOpts) bool { 34 | isClientMatching := opts.Client == nil || opts.Client.MatchString(entry.Client.Name) 35 | isProjectMatching := opts.Project == nil || opts.Project.MatchString(entry.Project.Name) 36 | 37 | return isClientMatching && isProjectMatching 38 | } 39 | 40 | // NewWorklog creates a worklog from the given set of entries and merges them. 41 | func NewWorklog(entries Entries, opts *FilterOpts) Worklog { 42 | var filteredEntries Entries 43 | 44 | worklog := Worklog{} 45 | mergedEntries := map[string]Entry{} 46 | 47 | for _, entry := range entries { 48 | if isEntryMatching(entry, opts) { 49 | filteredEntries = append(filteredEntries, entry) 50 | } 51 | } 52 | 53 | for _, entry := range filteredEntries { 54 | key := entry.Key() 55 | storedEntry, isStored := mergedEntries[key] 56 | 57 | if !isStored { 58 | mergedEntries[key] = entry 59 | continue 60 | } 61 | 62 | storedEntry.BillableDuration += entry.BillableDuration 63 | storedEntry.UnbillableDuration += entry.UnbillableDuration 64 | 65 | noteSeparator := "" 66 | if storedEntry.Notes != "" && entry.Notes != storedEntry.Notes { 67 | if entry.Notes != "" { 68 | noteSeparator = "; " 69 | } 70 | 71 | storedEntry.Notes = storedEntry.Notes + noteSeparator + entry.Notes 72 | } 73 | 74 | mergedEntries[key] = storedEntry 75 | } 76 | 77 | for _, entry := range mergedEntries { 78 | if entry.IsComplete() { 79 | worklog.completeEntries = append(worklog.completeEntries, entry) 80 | } else { 81 | worklog.incompleteEntries = append(worklog.incompleteEntries, entry) 82 | } 83 | } 84 | 85 | return worklog 86 | } 87 | -------------------------------------------------------------------------------- /internal/pkg/worklog/worklog_test.go: -------------------------------------------------------------------------------- 1 | package worklog_test 2 | 3 | import ( 4 | "regexp" 5 | "testing" 6 | "time" 7 | 8 | "github.com/gabor-boros/minutes/internal/pkg/worklog" 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | var newWorklogBenchResult worklog.Worklog 13 | var completeEntriesBenchResult worklog.Entries 14 | var incompleteEntriesBenchResult worklog.Entries 15 | 16 | func benchNewWorklog(b *testing.B, entryCount int) { 17 | b.StopTimer() 18 | 19 | var entries worklog.Entries 20 | 21 | for i := 0; i != entryCount; i++ { 22 | entry := getCompleteTestEntry() 23 | entry.Start.Add(time.Hour * time.Duration(i)) 24 | entries = append(entries, entry) 25 | } 26 | 27 | b.StartTimer() 28 | 29 | for n := 0; n != b.N; n++ { 30 | // always store the result to a package level variable 31 | // so the compiler cannot eliminate the Benchmark itself. 32 | newWorklogBenchResult = worklog.NewWorklog(entries, &worklog.FilterOpts{}) 33 | } 34 | } 35 | 36 | func benchmarkCompleteEntries(b *testing.B, entryCount int) { 37 | b.StopTimer() 38 | 39 | var entries worklog.Entries 40 | 41 | for i := 0; i != entryCount; i++ { 42 | entry := getCompleteTestEntry() 43 | entry.Start.Add(time.Hour * time.Duration(i)) 44 | entries = append(entries, entry) 45 | } 46 | 47 | wl := worklog.NewWorklog(entries, &worklog.FilterOpts{}) 48 | 49 | b.StartTimer() 50 | 51 | for n := 0; n != b.N; n++ { 52 | // always store the result to a package level variable 53 | // so the compiler cannot eliminate the Benchmark itself. 54 | completeEntriesBenchResult = wl.CompleteEntries() 55 | } 56 | } 57 | 58 | func benchmarkIncompleteEntries(b *testing.B, entryCount int) { 59 | b.StopTimer() 60 | 61 | var entries worklog.Entries 62 | 63 | for i := 0; i != entryCount; i++ { 64 | entry := getIncompleteTestEntry() 65 | entry.Start.Add(time.Hour * time.Duration(i)) 66 | entries = append(entries, entry) 67 | } 68 | 69 | wl := worklog.NewWorklog(entries, &worklog.FilterOpts{}) 70 | 71 | b.StartTimer() 72 | 73 | for n := 0; n != b.N; n++ { 74 | // always store the result to a package level variable 75 | // so the compiler cannot eliminate the Benchmark itself. 76 | incompleteEntriesBenchResult = wl.IncompleteEntries() 77 | } 78 | } 79 | 80 | func BenchmarkNewWorklog_10(b *testing.B) { 81 | benchNewWorklog(b, 10) 82 | _ = newWorklogBenchResult // Use the result to eliminate linter issues 83 | } 84 | 85 | func BenchmarkNewWorklog_1000(b *testing.B) { 86 | benchNewWorklog(b, 1000) 87 | _ = newWorklogBenchResult // Use the result to eliminate linter issues 88 | } 89 | 90 | func BenchmarkCompleteEntries_10(b *testing.B) { 91 | benchmarkCompleteEntries(b, 10) 92 | _ = completeEntriesBenchResult // Use the result to eliminate linter issues 93 | } 94 | 95 | func BenchmarkCompleteEntries_1000(b *testing.B) { 96 | benchmarkCompleteEntries(b, 1000) 97 | _ = completeEntriesBenchResult // Use the result to eliminate linter issues 98 | } 99 | 100 | func BenchmarkIncompleteEntries_10(b *testing.B) { 101 | benchmarkIncompleteEntries(b, 10) 102 | _ = incompleteEntriesBenchResult // Use the result to eliminate linter issues 103 | } 104 | 105 | func BenchmarkIncompleteEntries_1000(b *testing.B) { 106 | benchmarkIncompleteEntries(b, 1000) 107 | _ = incompleteEntriesBenchResult // Use the result to eliminate linter issues 108 | } 109 | 110 | func TestWorklogCompleteEntries(t *testing.T) { 111 | completeEntry := getCompleteTestEntry() 112 | 113 | otherCompleteEntry := getCompleteTestEntry() 114 | otherCompleteEntry.Notes = "Really" 115 | 116 | incompleteEntry := getCompleteTestEntry() 117 | incompleteEntry.Task = worklog.IDNameField{} 118 | 119 | wl := worklog.NewWorklog(worklog.Entries{ 120 | completeEntry, 121 | otherCompleteEntry, 122 | incompleteEntry, 123 | }, &worklog.FilterOpts{}) 124 | 125 | entry := wl.CompleteEntries()[0] 126 | assert.Equal(t, "It is a lot easier than expected; Really", entry.Notes) 127 | assert.Equal(t, worklog.Entries{entry}, wl.CompleteEntries()) 128 | } 129 | 130 | func TestWorklogIncompleteEntries(t *testing.T) { 131 | completeEntry := getCompleteTestEntry() 132 | 133 | incompleteEntry := getCompleteTestEntry() 134 | incompleteEntry.Task = worklog.IDNameField{} 135 | 136 | otherIncompleteEntry := getCompleteTestEntry() 137 | otherIncompleteEntry.Task = worklog.IDNameField{} 138 | otherIncompleteEntry.Notes = "Well, not that easy" 139 | 140 | wl := worklog.NewWorklog(worklog.Entries{ 141 | completeEntry, 142 | incompleteEntry, 143 | otherIncompleteEntry, 144 | }, &worklog.FilterOpts{}) 145 | 146 | entry := wl.IncompleteEntries()[0] 147 | assert.Equal(t, "It is a lot easier than expected; Well, not that easy", entry.Notes) 148 | assert.Equal(t, worklog.Entries{entry}, wl.IncompleteEntries()) 149 | } 150 | 151 | func TestWorklogFilterEntries(t *testing.T) { 152 | entry1 := getCompleteTestEntry() 153 | entry1.Client.Name = "ACME Inc." 154 | entry1.Project.Name = "redesign website" 155 | 156 | entry2 := getCompleteTestEntry() 157 | entry2.Client.Name = "ACME Incorporation" 158 | entry2.Project.Name = "website development" 159 | 160 | entry3 := getCompleteTestEntry() 161 | entry3.Client.Name = "Other Inc." 162 | entry3.Project.Name = "redesign website" 163 | 164 | entry4 := getCompleteTestEntry() 165 | entry4.Client.Name = "Another Inc." 166 | entry4.Project.Name = "website development" 167 | 168 | filterOpts := &worklog.FilterOpts{ 169 | Client: regexp.MustCompile(`^ACME Inc\.?(orporation)?$`), 170 | Project: regexp.MustCompile(`.*(website).*`), 171 | } 172 | 173 | wl := worklog.NewWorklog(worklog.Entries{ 174 | entry1, 175 | entry2, 176 | entry3, 177 | entry4, 178 | }, filterOpts) 179 | 180 | assert.ElementsMatch(t, worklog.Entries{entry1, entry2}, wl.CompleteEntries()) 181 | } 182 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/gabor-boros/minutes/cmd/root" 4 | 5 | var ( 6 | version string 7 | commit string 8 | date string 9 | ) 10 | 11 | func main() { 12 | root.Execute(version, commit, date) 13 | } 14 | -------------------------------------------------------------------------------- /www/docs/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ../../CHANGELOG.md -------------------------------------------------------------------------------- /www/docs/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ../../CONTRIBUTING.md -------------------------------------------------------------------------------- /www/docs/LICENSE.md: -------------------------------------------------------------------------------- 1 | ../../LICENSE -------------------------------------------------------------------------------- /www/docs/assets/css/minutes.css: -------------------------------------------------------------------------------- 1 | .md-typeset__table { 2 | min-width: 100%; 3 | } 4 | 5 | .md-typeset table:not([class]) { 6 | display: table; 7 | } 8 | -------------------------------------------------------------------------------- /www/docs/assets/img/hero.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gabor-boros/minutes/70fbbe5b78efcda1ac88855bac96a3986a82373a/www/docs/assets/img/hero.png -------------------------------------------------------------------------------- /www/docs/configuration.md: -------------------------------------------------------------------------------- 1 | This page documents the available settings for `minutes`. Please note that not all configuration options are covered by a CLI flag. 2 | 3 | ## Configuration file 4 | 5 | Minutes will look for the following places for the configuration file, based on your operating system. 6 | 7 | The configuration file name in **every** case is `.minutes.toml`. 8 | 9 | ### Linux/Unix 10 | 11 | On Linux/Unix systems, the following locations are checked for the configuration file: 12 | 13 | - `$HOME/.minutes.toml` 14 | - `$XDG_CONFIG_HOME/.minutes.toml` as specified by https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html 15 | 16 | ### Darwin 17 | 18 | - `$HOME/.minutes.toml` 19 | - `$HOME/Library/Application Support/.minutes.toml` 20 | 21 | ### Windows 22 | 23 | - `%USERPROFILE%/.minutes.toml` 24 | - `%AppData%/.minutes.toml` 25 | 26 | ### On Plan 9 27 | 28 | - `$home/.minutes.toml` 29 | - `$home/lib/.minutes.toml` 30 | 31 | ## Common configuration 32 | 33 | | Config option | Kind | Description | Example | Available options | 34 | | ----------------------- | --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | -------------------------------------------------------------------------------- | 35 | | date-format | string | Set the date format in [Go specific](https://www.geeksforgeeks.org/time-formatting-in-golang/) date format | date-format = "2006-01-02" | | 36 | | dry-run | bool | Fetch entries from source, print the fetched entries, but do not upload them | dry-run = true | | 37 | | end | string | Set the end date for fetching entries (must match the `date-format`) | end = "2021-10-01" | | 38 | | filter-client | string | Regex of the client name to filter for | filter-client = '^ACME Inc\.?(orporation)$' | | 39 | | filter-project | string | Regex of the project name to filter for | filter-project = '._(website)._' | | 40 | | force-billed-duration | bool | Treat the total spent time as billable time | force-billed-duration = true | | 41 | | round-to-closest-minute | bool | Round time to closest minute, even if the closest minute is 0 (zero) | round-to-closest-minute = true | | 42 | | source | string | Set the fetch source name | source = "tempo" | Check the list of available sources | 43 | | source-user | string | Set the fetch source user ID | source-user = "gabor-boros" | | 44 | | start | string | Set the start date for fetching entries (must match the `date-format`) | start = "2021-10-01" | | 45 | | table-column-config | [[]table.ColumnConfig][column config documentation] | Customize columns based on the underlying column config struct[^1] | table-column-config = { summary = { widthmax = 40 } } | | 46 | | table-hide-column | []string | Hide the specified columns of the printed overview table | table-hide-column = ["start", "end"] | `summary`, `project`, `client`, `start`, `end` | 47 | | table-sort-by | []string | Sort the specified rows of the printed table by the given column; each sort option can have a `-` (hyphen) prefix to indicate descending sort | table-sort-by = ["start", "task"] | `task`, `summary`, `project`, `client`, `start`, `end`, `billable`, `unbillable` | 48 | | table-truncate-column | map[string]int | Truncate text in the given column to contain no more than `x` characters, where `x` is set by `int` | table-truncate-column = { summary = 30 } | | 49 | | target | string | Set the upload target name | target = "tempo" | Check the list of available targets | 50 | | target-user | string | Set the upload target user ID | target = "gabor-boros" | | 51 | | tags-as-tasks-regex | string | Regex of the task pattern | tags-as-tasks-regex = '[A-Z]{2,7}-\d{1,6}' | | 52 | 53 | ## Source and target specific configuration 54 | 55 | Source and target specific configuration is **not** covered by this guide. For more information, please refer to the source or target documentation. 56 | 57 | ## Example configuration 58 | 59 | ```toml 60 | # Source config 61 | source = "clockify" 62 | source-user = "" 63 | 64 | clockify-url = "https://api.clockify.me" 65 | clockify-api-key = "" 66 | clockify-workspace = "" 67 | 68 | # Target config 69 | target = "tempo" 70 | target-user = "" 71 | 72 | tempo-url = "https://.atlassian.net" 73 | tempo-username = "" 74 | tempo-password = "" 75 | 76 | # General config 77 | tags-as-tasks-regex = '[A-Z]{2,7}-\d{1,6}' 78 | round-to-closest-minute = true 79 | force-billed-duration = true 80 | 81 | filter-client = '^ACME Inc\.?(orporation)$' 82 | filter-project = '.*(website).*' 83 | 84 | table-sort-by = [ 85 | "start", 86 | "project", 87 | "task", 88 | "summary", 89 | ] 90 | 91 | table-hide-column = [ 92 | "end" 93 | ] 94 | 95 | [table-column-truncates] 96 | summary = 40 97 | project = 10 98 | client = 10 99 | 100 | # Column Config 101 | [table-column-config.summary] 102 | widthmax = 40 103 | widthmin = 20 104 | ``` 105 | 106 | [^1]: The column configuration cannot be mapped directly as-is. Therefore, the configuration option names are lower-cased. Also, some settings cannot be used that would require Go code, like transformers. 107 | 108 | [column config documentation]: https://github.com/jedib0t/go-pretty/blob/b2f15441a4e4addd806df446c65f0ce5e327003c/table/config.go#L7-L71 109 | -------------------------------------------------------------------------------- /www/docs/getting-started.md: -------------------------------------------------------------------------------- 1 | Minutes is a CLI tool, primarily made for entrepreneurs and finance people, to help their daily work by synchronizing worklogs from a `source` to a `target` software. The `source` can be your own time tracking tool you use, while the `target` is a bookkeeping software. 2 | 3 | This guide show you the basics `minutes`, walks through the available flags, and gives some examples for basic configuration. For the full list of available configuration options, visit the related [documentation](https://gabor-boros.github.io/minutes/configuration). 4 | 5 | ## Installation 6 | 7 | ### Using `brew` 8 | 9 | ``` shell 10 | $ brew tap gabor-boros/brew 11 | $ brew install minutes 12 | ``` 13 | 14 | ### Manual install 15 | 16 | To install `minutes`, use one of the [release artifacts](https://github.com/gabor-boros/minutes/releases). If you have `go` installed, you can build from source as well 17 | 18 | ### Configuration 19 | 20 | `minutes` has numerous flags and there will be more when other sources or targets are added. Therefore, `minutes` comes with a config file, that can be placed to the user's home directory or the config directory. 21 | 22 | ## Usage 23 | 24 | ```plaintext 25 | Usage: 26 | minutes [flags] 27 | 28 | Flags: 29 | --clockify-api-key string set the API key 30 | --clockify-url string set the base URL 31 | --clockify-workspace string set the workspace ID 32 | --config string config file (default is $HOME/.minutes.yaml) 33 | --date-format string set start and end date format (in Go style) (default "2006-01-02 15:04:05") 34 | --dry-run fetch entries, but do not sync them 35 | --end string set the end date (defaults to now) 36 | --force-billed-duration treat every second spent as billed 37 | -h, --help help for minutes 38 | --round-to-closest-minute round time to closest minute 39 | -s, --source string set the source of the sync [clockify tempo] 40 | --source-user string set the source user ID 41 | --start string set the start date (defaults to 00:00:00) 42 | --table-hide-column strings hide table column [summary project client start end] 43 | --table-sort-by strings sort table by column [task summary project client start end billable unbillable] (default [start,project,task,summary]) 44 | -t, --target string set the target of the sync [tempo] 45 | --target-user string set the source user ID 46 | --tags-as-tasks-regex string regex of the task pattern 47 | --tempo-password string set the login password 48 | --tempo-url string set the base URL 49 | --tempo-username string set the login user ID 50 | --verbose print verbose messages 51 | --version show command version 52 | ``` 53 | 54 | ## Usage examples 55 | 56 | Depending on the config file, the number of flags can change. 57 | 58 | ### Simplest command 59 | 60 | ```shell 61 | # No arguments, no flags, just running the command 62 | $ minutes 63 | ``` 64 | 65 | ### Set specific date and time 66 | 67 | ```shell 68 | # Set the date and time to fetch entries in the given time frame 69 | $ minutes --start "2021-10-07 00:00:00" --end "2021-10-07 23:59:59" 70 | ``` 71 | 72 | ```shell 73 | # Specify the start and end date format 74 | $ minutes --date-format "2006-01-02" --start "2021-10-07" --end "2021-10-08" 75 | ``` 76 | 77 | ### Use tags for tasks 78 | 79 | ```shell 80 | # Specify how a tag should look like to be considered as a task 81 | $ minutes --tags-as-tasks-regex '[A-Z]{2,7}-\d{1,6}' 82 | ``` 83 | 84 | ### Minute based rounding 85 | 86 | ```shell 87 | # Set the billed and unbilled time separately 88 | # to round to the closest minute (even if it is zero) 89 | $ minutes --round-to-closest-minute 90 | ``` 91 | 92 | ### Format the table output 93 | 94 | ```shell 95 | # Skip some columns and sort table by -start date 96 | $ minutes --table-sort-by "-start" --table-hide-column "client" --table-hide-column "project" 97 | ``` 98 | 99 | ## Config file vs flags 100 | 101 | Be aware that not all configuration option is covered by flags, especially not more advanced options, like table column width or truncate settings. 102 | 103 | When using the configuration file and flags in conjunction, please note that flags take precedence, hence it can override settings from the configuration file. 104 | -------------------------------------------------------------------------------- /www/docs/index.md: -------------------------------------------------------------------------------- 1 |
2 |

Minutes

3 | 4 |

5 | Sync worklogs between multiple time trackers, invoicing, and bookkeeping software. 6 |
7 | Bug report 8 | · 9 | Feature request 10 |

11 | 12 |

13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 |

35 |
36 | 37 | --- 38 | 39 | ![minutes](./assets/img/hero.png) 40 | 41 | Charging by the hour is one of the most common ways to invoice a client. Some companies or clients specify the time tracking tool to use, though it usually won't fit your own workflows. This even gets more complicated and cumbersome, when the invoices should contain the tasks you spent time on. 42 | 43 | Minutes is a CLI tool, primarily made for entrepreneurs and finance people, to help their daily work by synchronizing worklogs from a `source` to a `target` software. The `source` can be your own time tracking tool you use, let's say [Tempo](https://tempo.io/), while the `target` is a bookkeeping software, like [Zoho Books](https://books.zoho.com). 44 | 45 | ## Key features 46 | 47 | _Some features may vary depending on the `source` and `target`. For more information, please refer to their documentation._ 48 | 49 | - Customize the date time format used for fetching entries 50 | - Set a specific start and end date to query entries 51 | - Force every second spent to be billable[^1] 52 | - Treat tags attached to an entry as tasks you spent time on[^2] 53 | - Round billable and unbillable seconds to the closest minute per entry[^3] 54 | - Customizable table output before uploading 55 | 56 | [^1]: It can be useful if the `source` does not support billable time, while the `target` does. 57 | [^2]: When you need to split time across entries or your `source` tool does not support tasks, it comes handy to tag entries. 58 | [^3]: Rounding rules are the following: if you spent >=30 seconds on a task, it will be treated as 1 minute, otherwise 0 (zero). 59 | 60 | For usage examples and configuration options, please check the [Getting Started](https://gabor-boros.github.io/minutes/getting-started) and [Configuration](https://gabor-boros.github.io/minutes/configuration) documentation. 61 | 62 | ## Supported platforms 63 | 64 | The following platforms and tools are supported. If you miss your favorite tool, please send a pull request with the implementation, or file a new [feature request](https://github.com/gabor-boros/minutes/issues). 65 | 66 | | Tool | Use as source | Use as target | 67 | | ----------- | ------------- | ------------- | 68 | | Clockify | **yes** | upon request | 69 | | Everhour | upon request | upon request | 70 | | FreshBooks | upon request | **planned** | 71 | | Harvest | **yes** | upon request | 72 | | QuickBooks | upon request | upon request | 73 | | Tempo | **yes** | **yes** | 74 | | Time Doctor | upon request | upon request | 75 | | TimeCamp | upon request | upon request | 76 | | Timewarrior | **yes** | upon request | 77 | | Toggl Track | **yes** | upon request | 78 | | Zoho Books | upon request | **planned** | 79 | 80 | ## Versioning 81 | 82 | Minutes adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 83 | 84 | ## Disclaimer 85 | 86 | !!! warning 87 | 88 | Minutes comes with absolutely **no warranty**. 89 | When money comes into the picture, everyone should be more careful what they are doing. Before and after synchronizing any logs to the `target`, **make sure the entries displayed on the screen matching the `source` entries**. 90 | -------------------------------------------------------------------------------- /www/docs/migrations/tempoit.md: -------------------------------------------------------------------------------- 1 | Migrating from [Tempoit](https://sr.ht/%7Eswalladge/tempoit/). 2 | 3 | ## Recommended config 4 | 5 | ```toml 6 | # Source config 7 | source = "timewarrior" 8 | source-user = "-" 9 | 10 | # Timewarrior config 11 | timewarrior-arguments = ["log"] 12 | timewarrior-client-tag-regex = '^(oc)$' 13 | timewarrior-project-tag-regex = '^(log)$' 14 | 15 | # Target config 16 | target = "tempo" 17 | target-user = "" 18 | 19 | # Tempo config 20 | tempo-url = "https://.atlassian.net" 21 | tempo-username = "" 22 | tempo-password = "" 23 | 24 | # General config 25 | tags-as-tasks-regex = '[A-Z]{2,7}-\d{1,6}' 26 | round-to-closest-minute = true 27 | force-billed-duration = true 28 | ``` -------------------------------------------------------------------------------- /www/docs/migrations/toggl-tempo-worklog-transfer.md: -------------------------------------------------------------------------------- 1 | Migrating from [Toggl to Jira worklog transfer](https://github.com/giovannicimolin/toggl-tempo-worklog-transfer). 2 | 3 | !!! warning 4 | 5 | To get your Toggl user ID, please check the [source documentation](https://gabor-boros.github.io/minutes/sources/toggl/) 6 | of Toggl. 7 | 8 | ## Recommended config 9 | 10 | ```toml 11 | # Source config 12 | source = "toggl" 13 | source-user = "" 14 | 15 | # Toggl config 16 | toggl-api-key = "" 17 | toggl-url = "https://api.track.toggl.com" 18 | toggl-workspace = "" 19 | 20 | # Target config 21 | target = "tempo" 22 | target-user = "" 23 | 24 | # Tempo config 25 | tempo-url = "https://.atlassian.net" 26 | tempo-username = "" 27 | tempo-password = "" 28 | 29 | # General config 30 | tags-as-tasks-regex = '[A-Z]{2,7}-\d{1,6}' 31 | round-to-closest-minute = true 32 | force-billed-duration = true 33 | ``` -------------------------------------------------------------------------------- /www/docs/sources/clockify.md: -------------------------------------------------------------------------------- 1 | Source documentation for [Clockify](https://clockify.me/). 2 | 3 | ## Field mappings 4 | 5 | The source makes the following special mappings. 6 | 7 | | From | To | Description | 8 | | ----------- | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | 9 | | Tags | Task | Turns tags into tasks and split the entry into as many pieces as the item has matching tags when `tags-as-tasks-regex` is set | 10 | | Task | Summary or Description | Tasks will be used for defining the summary of an entry; in case the `tags-as-tasks-regex` is set, Summary will be set to the Description of the item | 11 | 12 | ## CLI flags 13 | 14 | The source provides to following extra CLI flags. 15 | 16 | ```plaintext 17 | Flags: 18 | --clockify-api-key string set the API key (default "https://clockify.me") 19 | --clockify-url string set the base URL 20 | --clockify-workspace string set the workspace ID 21 | ``` 22 | 23 | ## Configuration options 24 | 25 | The source provides the following extra configuration options. 26 | 27 | | Config option | Kind | Description | Example | 28 | | ------------------ | ------ | ---------------------------------------------------------- | ------------------------------------- | 29 | | clockify-url | string | URL for the Clockify installation without a trailing slash | clockify-url = "https://clockify.me" | 30 | | clockify-api-key | string | API key gathered from Clockify[^1] | clockify-api-key = "" | 31 | | clockify-workspace | string | Clockify workspace ID[^2] | clockify-workspace = "" | 32 | 33 | ## Limitations 34 | 35 | No known limitations. 36 | 37 | ## Example configuration 38 | 39 | ```toml 40 | # Source config 41 | source = "clockify" 42 | source-user = "" 43 | 44 | clockify-url = "https://api.clockify.me" 45 | clockify-api-key = "" 46 | clockify-workspace = "" 47 | 48 | # Target config 49 | target = "tempo" 50 | target-user = "" 51 | 52 | tempo-url = "https://.atlassian.net" 53 | tempo-username = "" 54 | tempo-password = "" 55 | 56 | # General config 57 | tags-as-tasks-regex = '[A-Z]{2,7}-\d{1,6}' 58 | 59 | round-to-closest-minute = true 60 | force-billed-duration = true 61 | ``` 62 | 63 | [^1]: As described in the [API documentation](https://clockify.me/developers-api), visit the [settings](https://clockify.me/user/settings) page to get your API token. 64 | [^2]: To get your workspace ID, navigate to workspace settings and copy the ID from the URL. 65 | -------------------------------------------------------------------------------- /www/docs/sources/harvest.md: -------------------------------------------------------------------------------- 1 | Source documentation for [Harvest](https://getharvest.com/). 2 | 3 | ## Field mappings 4 | 5 | The source makes the following special mappings. 6 | 7 | | From | To | Description | 8 | | ----- | -------------- | --------------------------------------------------------------------------------- | 9 | | Notes | Notes, Summary | Notes are mapped to both notes and summary as that was the most meaningful option | 10 | 11 | ## CLI flags 12 | 13 | The source provides to following extra CLI flags. 14 | 15 | ```plaintext 16 | Flags: 17 | --harvest-account int set the Account ID 18 | --harvest-api-key string set the API key 19 | ``` 20 | 21 | ## Configuration options 22 | 23 | The source provides the following extra configuration options. 24 | 25 | | Config option | Kind | Description | Example | 26 | | --------------- | ------ | ------------------------------------------- | ----------------------------- | 27 | | harvest-account | string | The account ID where the API key belongs to | harvest-account = 123456789 | 28 | | harvest-api-key | string | API key gathered from Harvest[^1] | harvest-api-key = "" | 29 | 30 | ## Limitations 31 | 32 | * Harvest does not support tags which makes it impossible to get tasks from tags. A workaround is [planned](https://github.com/gabor-boros/minutes/issues/32). 33 | 34 | ## Example configuration 35 | 36 | ```toml 37 | # Source config 38 | source = "harvest" 39 | source-user = "" 40 | 41 | harvest-account = "" 42 | harvest-api-key = "" 43 | 44 | # Target config 45 | target = "tempo" 46 | target-user = "" 47 | 48 | tempo-url = "https://.atlassian.net" 49 | tempo-username = "" 50 | tempo-password = "" 51 | 52 | # General config 53 | round-to-closest-minute = true 54 | force-billed-duration = true 55 | ``` 56 | 57 | [^1]: Create a new "Personal Access Token" on the [developer settings](https://id.getharvest.com/developers) panel. 58 | -------------------------------------------------------------------------------- /www/docs/sources/tempo.md: -------------------------------------------------------------------------------- 1 | Source documentation for [Tempo](https://tempo.io/). 2 | 3 | ## Field mappings 4 | 5 | The source makes the following special mappings. 6 | 7 | | From | To | Description | 8 | | ---------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | 9 | | AccountKey | Client | | 10 | | ProjectKey | Project | Tasks will be used for defining the summary of an entry; in case the `tags-as-tasks-regex` is set, Summary will be set to the Description of the item | 11 | | IssueKey | Task | | 12 | | Comment | Notes | | 13 | 14 | ## CLI flags 15 | 16 | The source provides to following extra CLI flags. 17 | 18 | ```plaintext 19 | Flags: 20 | --tempo-password string set the login password 21 | --tempo-url string set the base URL 22 | --tempo-username string set the login user ID 23 | ``` 24 | 25 | ## Configuration options 26 | 27 | The source provides the following extra configuration options. 28 | 29 | | Config option | Kind | Description | Example | 30 | | -------------- | ------ | ------------------------------------------------------ | ------------------------------------------- | 31 | | tempo-password | string | Jira password | tempo-password = "" | 32 | | tempo-url | string | URL for the Jira installation without a trailing slash | tempo-url = "https://example.atlassian.net" | 33 | | tempo-username | string | Jira username | tempo-username = "gabor-boros" | 34 | 35 | ## Limitations 36 | 37 | No known limitations. 38 | 39 | ## Example configuration 40 | 41 | !!! warning 42 | 43 | At the moment only one target is supported, Tempo, hence tempo cannot be used as a source yet. 44 | 45 | ```toml 46 | # Source config 47 | source = "tempo" 48 | source-user = "" 49 | 50 | # Target config 51 | target = "" 52 | target-user = "" 53 | 54 | # Tempo config 55 | tempo-url = "https://.atlassian.net" 56 | tempo-username = "" 57 | tempo-password = "" 58 | 59 | # General config 60 | tags-as-tasks-regex = '[A-Z]{2,7}-\d{1,6}' 61 | round-to-closest-minute = true 62 | force-billed-duration = true 63 | ``` 64 | -------------------------------------------------------------------------------- /www/docs/sources/timewarrior.md: -------------------------------------------------------------------------------- 1 | Source documentation for [Timewarrior](https://timewarrior.net/). 2 | 3 | Timewarrior is one of the most flexible tools. Thanks to its flexibility there is no built-in/dedicated way to mark an entry billable/unbillable, set client, project, or task. 4 | 5 | Therefore, several assumptions were made to integrate with Timewarrior, though the goal was to keep the maximum flexibility. 6 | 7 | !!! warning 8 | 9 | Timewarrior has no built-in support for marking an entry billable/unbillable. Therefore, every entry will be treated 10 | as billable unless it is not forced by `force-billed-duration` or a matching tag for `timewarrior-unbillable-tag`. 11 | 12 | !!! warning 13 | 14 | When `timewarrior-client-tag-regex` or `timewarrior-project-tag-regex` is matching multiple tags, the last tag will be used. 15 | 16 | !!! warning 17 | 18 | To extract tasks from tags, set the `tags-as-tasks-regex`. 19 | 20 | ## Field mappings 21 | 22 | The source makes the following special mappings. 23 | 24 | | From | To | Description | 25 | | ---------- | --------------------------------- | -------------------------------------------------------------------------------------------------------- | 26 | | Annotation | Notes, Summary, Task (optionally) | Annotations are used to set Notes and Summary; if no task regex is set, it will be used for Task as well | 27 | | Tags | Client, Project, Task | Depending on the client, project, and task regex, tags will be used accordingly | 28 | 29 | ## CLI flags 30 | 31 | The source provides to following extra CLI flags. 32 | 33 | ```plaintext 34 | Flags: 35 | --timewarrior-arguments strings set additional arguments 36 | --timewarrior-client-tag-regex string regex of client tag pattern 37 | --timewarrior-command string set the executable name (default "timew") 38 | --timewarrior-project-tag-regex string regex of project tag pattern 39 | --timewarrior-unbillable-tag string set the unbillable tag (default "unbillable") 40 | ``` 41 | 42 | ## Configuration options 43 | 44 | The source provides the following extra configuration options. 45 | 46 | | Config option | Kind | Description | Example | 47 | | ----------------------------- | ------- | ------------------------------------------------------------------- | ------------------------------------------------ | 48 | | timewarrior-arguments | []string | Set additional arguments for the export command | timewarrior-arguments = "reviewed" | 49 | | timewarrior-client-tag-regex | string | Set the regular expression for extracting Client names from tags | timewarrior-client-tag-regex = '^(CLIENT-\w+)$' | 50 | | timewarrior-command | string | Set the timewarrior command | timewarrior-command = "timew" | 51 | | timewarrior-project-tag-regex | string | Set the regular expression for extracting Project names from tags | timewarrior-project-tag-regex = '^PROJ-DEV-\w+$' | 52 | | timewarrior-unbillable-tag | string | Set the regular expression to identify which entries are unbillable | timewarrior-unbillable-tag = "unbillable" | 53 | 54 | ## Limitations 55 | 56 | No known limitations. 57 | 58 | ## Example configuration 59 | 60 | ```toml 61 | # Source config 62 | source = "timewarrior" 63 | source-user = "-" # Timewarrior does not support multiple users 64 | 65 | # Timewarrior config 66 | timewarrior-arguments = ["log"] 67 | timewarrior-client-tag-regex = '^(oc)$' 68 | timewarrior-project-tag-regex = '^(log)$' 69 | 70 | # Target config 71 | target = "tempo" 72 | target-user = "" 73 | 74 | # Tempo config 75 | tempo-url = "https://.atlassian.net" 76 | tempo-username = "" 77 | tempo-password = "" 78 | 79 | # General config 80 | tags-as-tasks-regex = '[A-Z]{2,7}-\d{1,6}' 81 | round-to-closest-minute = true 82 | force-billed-duration = true 83 | ``` 84 | -------------------------------------------------------------------------------- /www/docs/sources/toggl.md: -------------------------------------------------------------------------------- 1 | Source documentation for [Toggl Track](https://track.toggl.com/). 2 | 3 | !!! warning 4 | 5 | To get the available User IDs, please follow [this instruction](https://github.com/toggl/toggl_api_docs/blob/master/chapters/workspaces.md#get-workspace-users). 6 | Only **workspace admins** can get the User IDs. 7 | 8 | !!! info 9 | 10 | Toggl Track's detailed report API does support filtering, and `minutes` unexplicitly supports filtering by setting, 11 | the source-user to the desired user ID, however it is not officially supported yet. 12 | 13 | ## Field mappings 14 | 15 | The source makes the following special mappings. 16 | 17 | | From | To | Description | 18 | | ----------- | ------- | -------------------------------------------------------- | 19 | | Description | Summary | Toggl Track has no option to set description for entries | 20 | 21 | ## CLI flags 22 | 23 | The source provides to following extra CLI flags. 24 | 25 | ```plaintext 26 | Flags: 27 | --toggl-api-key string set the API key 28 | --toggl-url string set the base URL (default "https://api.track.toggl.com") 29 | --toggl-workspace int set the workspace ID 30 | ``` 31 | 32 | ## Configuration options 33 | 34 | The source provides the following extra configuration options. 35 | 36 | | Config option | Kind | Description | Example | 37 | | --------------- | ------ | ------------------------------------------------------------- | ----------------------------------------- | 38 | | toggl-api-key | string | API key gathered from Toggl Track[^1] | toggl-api-key = "" | 39 | | toggl-workspace | int | Set the workspace ID | toggl-workspace = 123456789 | 40 | 41 | ## Limitations 42 | 43 | - No precise start and end date filtering is accepted by Toggl Track **report API** that is used for this source, therefore only ISO 8601 (`YYYY-MM-DD`) date format can be used. In Go it is translated to `2006-01-02` when setting `date-format` in config or flags. 44 | 45 | ## Example configuration 46 | 47 | ```toml 48 | # Source config 49 | source = "toggl" 50 | 51 | # To retrieve your user ID, please follow the instructions listed here: 52 | # https://github.com/toggl/toggl_api_docs/blob/master/chapters/workspaces.md#get-workspace-users 53 | source-user = "" 54 | 55 | # Toggl config 56 | toggl-api-key = "" 57 | toggl-url = "https://api.track.toggl.com" 58 | toggl-workspace = "" 59 | 60 | # Target config 61 | target = "tempo" 62 | target-user = "" 63 | 64 | # Tempo config 65 | tempo-url = "https://.atlassian.net" 66 | tempo-username = "" 67 | tempo-password = "" 68 | 69 | # General config 70 | tags-as-tasks-regex = '[A-Z]{2,7}-\d{1,6}' 71 | round-to-closest-minute = true 72 | force-billed-duration = true 73 | ``` 74 | 75 | [^1]: The API key can be generated as described in their [documentation](https://support.toggl.com/en/articles/3116844-where-is-my-api-key-located). 76 | -------------------------------------------------------------------------------- /www/docs/targets/tempo.md: -------------------------------------------------------------------------------- 1 | Target documentation for [Tempo](https://tempo.io/). 2 | 3 | !!! warning 4 | 5 | Tempo can go crazy when not a whole minute is uploaded. It is highly recommended using the `round-to-closest-minute` option. 6 | 7 | ## Field mappings 8 | 9 | The target makes the following special mappings. 10 | 11 | | From | To | Description | 12 | | ---------- | ------------ | --------------------------------------------------------------------------------------------- | 13 | | Summary | Comment | The entry summary will be used as the comment | 14 | | Task | OriginTaskID | Since OriginTaskID must be an Issue Key, the Issue Key defined by Task must represent in Jira | 15 | | tempo-user | Worker | | 16 | 17 | ## CLI flags 18 | 19 | The target does not provide additional CLI flags. 20 | 21 | ## Configuration options 22 | 23 | The target does not provide additional configuration options. 24 | 25 | ## Limitations 26 | 27 | - It is not possible to filter for projects when fetching, though it is a [planned](https://github.com/gabor-boros/minutes/issues/1) feature. 28 | - Tempo entries cannot have Summary and Notes at the same time, therefore we use Summary for the comment field during upload. 29 | - At the moment, it is not possible to upload an entry in the name of someone else. 30 | -------------------------------------------------------------------------------- /www/mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: Minutes 2 | site_url: https://github.com/gabor-boros/minutes 3 | site_description: Sync worklogs between multiple time trackers, invoicing, and bookkeeping software. 4 | copyright: Made with ❤️ by Minutes contributors. 5 | repo_name: gabor-boros/minutes 6 | repo_url: https://github.com/gabor-boros/minutes 7 | edit_uri: edit/main/www/docs/ 8 | 9 | theme: 10 | name: material 11 | language: en 12 | include_search_page: false 13 | search_index_only: true 14 | features: 15 | - navigation.tracking 16 | - navigation.top 17 | palette: 18 | - media: "(prefers-color-scheme: light)" # Light mode 19 | scheme: default 20 | primary: light blue 21 | accent: blue 22 | toggle: 23 | icon: material/toggle-switch-off-outline 24 | name: Switch to light mode 25 | - media: "(prefers-color-scheme: dark)" # Dark mode 26 | scheme: slate 27 | primary: light blue 28 | accent: blue 29 | toggle: 30 | icon: material/toggle-switch-outline 31 | name: Switch to dark mode 32 | 33 | plugins: 34 | - minify: 35 | minify_html: true 36 | - search: 37 | lang: 38 | - en 39 | 40 | extra: 41 | social: 42 | - icon: fontawesome/brands/github-alt 43 | link: https://github.com/gabor-boros/minutes 44 | 45 | markdown_extensions: 46 | - admonition 47 | - codehilite 48 | - footnotes 49 | - meta 50 | - pymdownx.highlight 51 | - pymdownx.superfences 52 | - toc: 53 | permalink: true 54 | - pymdownx.tasklist: 55 | custom_checkbox: true 56 | 57 | extra_css: 58 | - assets/css/minutes.css 59 | 60 | nav: 61 | - Introduction: index.md 62 | - getting-started.md 63 | - configuration.md 64 | - Sources: 65 | - Clockify: sources/clockify.md 66 | - Harvest: sources/harvest.md 67 | - Tempo: sources/tempo.md 68 | - Timewarrior: sources/timewarrior.md 69 | - Toggl Track: sources/toggl.md 70 | - Targets: 71 | - targets/tempo.md 72 | - Migrations: 73 | - From "Tempoit": migrations/tempoit.md 74 | - From "Toggl to Jira": migrations/toggl-tempo-worklog-transfer.md 75 | - Contributing: CONTRIBUTING.md 76 | - Changelog: CHANGELOG.md 77 | - License: LICENSE.md 78 | -------------------------------------------------------------------------------- /www/requirements.txt: -------------------------------------------------------------------------------- 1 | mkdocs-material==9.5.26 2 | mkdocs-minify-plugin==0.8.0 3 | --------------------------------------------------------------------------------