harvest-time-off 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/README.md +63 -0
- data/bin/harvest-time-off +6 -0
- data/harvest-time-off.rb +7 -0
- data/lib/harvest_time_off/version.rb +5 -0
- data/lib/harvest_time_off.rb +134 -0
- metadata +88 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: d14f2defb88e655452e5c21a45c28586127977e4e38976628cc9caf2b4fa88d2
|
|
4
|
+
data.tar.gz: eb1f5d5c3f25a4a65e24d74aeef9a6d186248134d96c542496ac476cbe82748c
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: e29d62624542e6cbc648138d9ec29e998bf16469e965bc00d03b8e5b82760b21ee73848b4d0fd7c216b79239790cd3033980f85225d50a65cc8a67ccd3b63b19
|
|
7
|
+
data.tar.gz: c0c27dc68af09a0e8f6a5d1e983054ce83205b6ab0035e03d086b505d1bd7258ed600b558c60b7c7428d1bffca065855420467eba17c91cb0dc8430d6192a4ca
|
data/README.md
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# Harvest Time Off
|
|
2
|
+
|
|
3
|
+
OMP-native Harvest integration. V1 makes holiday-aware time-off blocks convenient; it does not automate ordinary work tracking.
|
|
4
|
+
|
|
5
|
+
## Credentials
|
|
6
|
+
|
|
7
|
+
1. Open [Harvest ID Developers](https://id.getharvest.com/developers) and create a Personal Access Token for this local script.
|
|
8
|
+
2. Harvest displays the token and the available Harvest account IDs once. Copy the account ID for the account to track time in.
|
|
9
|
+
3. Fill the ignored `.envrc` in this repository:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
export HARVEST_ACCESS_TOKEN="your-personal-access-token"
|
|
13
|
+
export HARVEST_ACCOUNT_ID="your-harvest-account-id"
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
4. Run `direnv allow` if you use direnv; otherwise run `source .envrc` in the shell that starts OMP or the CLI.
|
|
17
|
+
|
|
18
|
+
Personal Access Tokens are the intended Harvest authentication method for personal scripts. Revoke and replace a token from Harvest ID if it is exposed. [Harvest authentication documentation](https://help.getharvest.com/api-v2/authentication-api/authentication/authentication/).
|
|
19
|
+
|
|
20
|
+
## V1: time off
|
|
21
|
+
|
|
22
|
+
The approval-gated `harvest_time_off` OMP tool and `harvest-time-off` CLI create 7-hour entries for each Yukon business day in an inclusive range. They skip weekends and observed Yukon holidays by default.
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
harvest-time-off 2026-08-17 2026-08-28 \
|
|
26
|
+
--project 'Time Off - Marlen' \
|
|
27
|
+
--task 'Vacation / PTO' \
|
|
28
|
+
--notes 'regular time off' \
|
|
29
|
+
--dry-run
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
The default holiday region is `ca_yt`. Add `--holiday-region REGION` or set `HARVEST_HOLIDAY_REGIONS` to include other [Holidays](https://github.com/holidays/holidays) regions. `business_time` calculates business days and `holidays` supplies observed statutory holidays.
|
|
33
|
+
|
|
34
|
+
A time-off block never creates weekend entries. The underlying [`marlens-harvest-api-v2`](https://github.com/klondikemarlen/harvest-api-v2) client accepts any ISO date, including weekends, for ordinary work entries.
|
|
35
|
+
|
|
36
|
+
## OMP settings
|
|
37
|
+
|
|
38
|
+
- `defaultHours`: hours per business day when a tool call omits `hours`; defaults to `7`.
|
|
39
|
+
- `holidayRegions`: comma-separated Holidays regions; defaults to `ca_yt`.
|
|
40
|
+
- `command`: direct path to the `harvest-time-off` executable.
|
|
41
|
+
|
|
42
|
+
## V2: OMP Project Time integration
|
|
43
|
+
|
|
44
|
+
[Issue #3](https://github.com/klondikemarlen/harvest-time-off/issues/3) tracks the next scope: turn reviewed `omp-project-time` output into Harvest work entries with configured project/task mappings, generated descriptions, duplicate/lock checks, preview, and approval. V1 remains deliberately focused on time off.
|
|
45
|
+
|
|
46
|
+
## Release
|
|
47
|
+
|
|
48
|
+
Before merging a release PR, self-review its complete diff, address every actionable review comment, and rerun focused QA after fixups. Record the review and QA evidence on the PR. Then merge to `main`, build and publish the Ruby gem when its `marlens-harvest-api-v2` dependency is available on RubyGems, install the released OMP plugin from GitHub, and verify it:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
ruby test_harvest_time_off.rb
|
|
52
|
+
npm run test:omp
|
|
53
|
+
harvest-time-off 2026-08-17 2026-08-28 \
|
|
54
|
+
--project 'Time Off - Marlen' \
|
|
55
|
+
--task 'Vacation / PTO' \
|
|
56
|
+
--holiday-region ca_yt \
|
|
57
|
+
--dry-run
|
|
58
|
+
gem build harvest-time-off.gemspec
|
|
59
|
+
gem push harvest-time-off-<version>.gem
|
|
60
|
+
omp plugin install --force github:klondikemarlen/harvest-time-off
|
|
61
|
+
omp plugin list
|
|
62
|
+
harvest-time-off --help
|
|
63
|
+
```
|
data/harvest-time-off.rb
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "harvest_time_off/version"
|
|
4
|
+
require "business_time"
|
|
5
|
+
require "date"
|
|
6
|
+
require "holidays"
|
|
7
|
+
require "optparse"
|
|
8
|
+
require "marlens/harvest_api_v2"
|
|
9
|
+
|
|
10
|
+
module HarvestTimeOff
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def dates_between(from, to, holiday_regions:)
|
|
14
|
+
raise Error, "end date must not be before start date" if to < from
|
|
15
|
+
raise Error, "at least one holiday region is required" if holiday_regions.empty?
|
|
16
|
+
|
|
17
|
+
holidays = Holidays.between(from, to, *(holiday_regions.map { |region| region.downcase.to_sym } + [:observed])).map { |holiday| holiday[:date] }
|
|
18
|
+
(from..to).select { |date| date.workday?(holidays:) }
|
|
19
|
+
rescue Holidays::InvalidRegion
|
|
20
|
+
raise Error, "invalid holiday region: #{holiday_regions.join(", ")}"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def display_hours(hours)
|
|
24
|
+
format("%g", hours)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
class Error < StandardError; end
|
|
28
|
+
|
|
29
|
+
class CLI
|
|
30
|
+
def self.run(arguments, output: $stdout, error: $stderr, client: nil)
|
|
31
|
+
options = { hours: 7.0, dry_run: false, holiday_regions: holiday_regions_from_environment }
|
|
32
|
+
parser = option_parser(options)
|
|
33
|
+
dates = parser.parse(arguments)
|
|
34
|
+
validate!(dates, options)
|
|
35
|
+
|
|
36
|
+
from = Date.iso8601(dates[0])
|
|
37
|
+
to = Date.iso8601(dates[1])
|
|
38
|
+
workdays = HarvestTimeOff.dates_between(from, to, holiday_regions: options[:holiday_regions])
|
|
39
|
+
raise Error, "date range contains no days to enter" if workdays.empty?
|
|
40
|
+
|
|
41
|
+
if options[:dry_run]
|
|
42
|
+
print_dry_run(output, workdays, options)
|
|
43
|
+
return 0
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
client ||= Marlens::HarvestApiV2::Client.from_environment
|
|
47
|
+
project_id, task_id = if options[:project_id]
|
|
48
|
+
[options[:project_id], options[:task_id]]
|
|
49
|
+
else
|
|
50
|
+
resolve_assignment(client, options[:project], options[:task])
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
workdays.each do |date|
|
|
54
|
+
entry = client.create_time_entry(
|
|
55
|
+
project_id:,
|
|
56
|
+
task_id:,
|
|
57
|
+
spent_date: date,
|
|
58
|
+
hours: options[:hours],
|
|
59
|
+
notes: options[:notes]
|
|
60
|
+
)
|
|
61
|
+
output.puts "Created #{date.iso8601}: #{HarvestTimeOff.display_hours(options[:hours])}h (entry ##{entry.fetch("id")})"
|
|
62
|
+
end
|
|
63
|
+
0
|
|
64
|
+
rescue Error, Marlens::HarvestApiV2::Error, OptionParser::ParseError, Date::Error => e
|
|
65
|
+
error.puts "Error: #{e.message}"
|
|
66
|
+
error.puts parser if parser
|
|
67
|
+
1
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def self.option_parser(options)
|
|
71
|
+
OptionParser.new do |opts|
|
|
72
|
+
opts.banner = <<~USAGE
|
|
73
|
+
Usage: harvest-time-off FROM TO --project NAME --task NAME [options]
|
|
74
|
+
harvest-time-off FROM TO --project-id ID --task-id ID [options]
|
|
75
|
+
|
|
76
|
+
Creates one Harvest duration entry for each local business day from FROM through TO.
|
|
77
|
+
USAGE
|
|
78
|
+
opts.on("--project NAME", "Harvest project name") { |value| options[:project] = value }
|
|
79
|
+
opts.on("--task NAME", "Harvest task name") { |value| options[:task] = value }
|
|
80
|
+
opts.on("--project-id ID", Integer, "Harvest project ID") { |value| options[:project_id] = value }
|
|
81
|
+
opts.on("--task-id ID", Integer, "Harvest task ID") { |value| options[:task_id] = value }
|
|
82
|
+
opts.on("--hours HOURS", Float, "Hours per day (default: 7)") { |value| options[:hours] = value }
|
|
83
|
+
opts.on("--notes NOTES", "Optional note on every entry") { |value| options[:notes] = value }
|
|
84
|
+
opts.on("--holiday-region REGION", "Holidays region; repeat for each locality") { |value| options[:holiday_regions] << value.strip.downcase }
|
|
85
|
+
opts.on("--dry-run", "Print entries without calling Harvest") { options[:dry_run] = true }
|
|
86
|
+
opts.on("-h", "--help", "Show this help") do
|
|
87
|
+
puts opts
|
|
88
|
+
exit 0
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def self.validate!(dates, options)
|
|
94
|
+
raise Error, "FROM and TO are required" unless dates.length == 2
|
|
95
|
+
raise Error, "--hours must be a positive finite number" unless options[:hours].positive? && options[:hours].finite?
|
|
96
|
+
raise Error, "--holiday-region or HARVEST_HOLIDAY_REGIONS is required" if options[:holiday_regions].empty?
|
|
97
|
+
|
|
98
|
+
names_provided = options[:project] || options[:task]
|
|
99
|
+
ids_provided = options[:project_id] || options[:task_id]
|
|
100
|
+
valid_names = options[:project] && options[:task]
|
|
101
|
+
valid_ids = options[:project_id] && options[:task_id]
|
|
102
|
+
raise Error, "supply --project and --task, or --project-id and --task-id" unless valid_names || valid_ids
|
|
103
|
+
raise Error, "do not combine project/task names with IDs" if names_provided && ids_provided
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def self.holiday_regions_from_environment
|
|
107
|
+
ENV.fetch("HARVEST_HOLIDAY_REGIONS", "ca_yt").split(",").map { |region| region.strip.downcase }.reject(&:empty?)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def self.resolve_assignment(client, project_name, task_name)
|
|
111
|
+
# ponytail: one 2,000-item page covers personal assignments; follow cursor pagination if that ceiling is exceeded.
|
|
112
|
+
matches = client.active_task_assignments.select do |assignment|
|
|
113
|
+
assignment.dig("project", "name")&.casecmp?(project_name) &&
|
|
114
|
+
assignment.dig("task", "name")&.casecmp?(task_name)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
return [matches.first.dig("project", "id"), matches.first.dig("task", "id")] if matches.one?
|
|
118
|
+
|
|
119
|
+
qualifier = "project #{project_name.inspect} and task #{task_name.inspect}"
|
|
120
|
+
raise Error, "No active task assignment matches #{qualifier}. Pass --project-id and --task-id instead." if matches.empty?
|
|
121
|
+
|
|
122
|
+
raise Error, "Multiple active task assignments match #{qualifier}. Pass --project-id and --task-id instead."
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def self.print_dry_run(output, dates, options)
|
|
126
|
+
project = options[:project] || "project ##{options[:project_id]}"
|
|
127
|
+
task = options[:task] || "task ##{options[:task_id]}"
|
|
128
|
+
notes = options[:notes] ? "; #{options[:notes]}" : ""
|
|
129
|
+
dates.each do |date|
|
|
130
|
+
output.puts "Would create #{date.iso8601}: #{HarvestTimeOff.display_hours(options[:hours])}h on #{project} / #{task}#{notes}"
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: harvest-time-off
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.2.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Marlen Brunner
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: marlens-harvest-api-v2
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '0.1'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '0.1'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: business_time
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '0.13'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '0.13'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: holidays
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - "~>"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '9.2'
|
|
47
|
+
type: :runtime
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - "~>"
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '9.2'
|
|
54
|
+
email:
|
|
55
|
+
- klondikemarlen@gmail.com
|
|
56
|
+
executables:
|
|
57
|
+
- harvest-time-off
|
|
58
|
+
extensions: []
|
|
59
|
+
extra_rdoc_files: []
|
|
60
|
+
files:
|
|
61
|
+
- README.md
|
|
62
|
+
- bin/harvest-time-off
|
|
63
|
+
- harvest-time-off.rb
|
|
64
|
+
- lib/harvest_time_off.rb
|
|
65
|
+
- lib/harvest_time_off/version.rb
|
|
66
|
+
homepage: https://github.com/klondikemarlen/harvest-time-off
|
|
67
|
+
licenses:
|
|
68
|
+
- MIT
|
|
69
|
+
metadata:
|
|
70
|
+
source_code_uri: https://github.com/klondikemarlen/harvest-time-off
|
|
71
|
+
rdoc_options: []
|
|
72
|
+
require_paths:
|
|
73
|
+
- lib
|
|
74
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
75
|
+
requirements:
|
|
76
|
+
- - ">="
|
|
77
|
+
- !ruby/object:Gem::Version
|
|
78
|
+
version: '3.2'
|
|
79
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
80
|
+
requirements:
|
|
81
|
+
- - ">="
|
|
82
|
+
- !ruby/object:Gem::Version
|
|
83
|
+
version: '0'
|
|
84
|
+
requirements: []
|
|
85
|
+
rubygems_version: 4.0.3
|
|
86
|
+
specification_version: 4
|
|
87
|
+
summary: Create Harvest time-off entries over a date range.
|
|
88
|
+
test_files: []
|