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 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
+ ```
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "harvest_time_off"
5
+
6
+ exit HarvestTimeOff::CLI.run(ARGV)
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ $LOAD_PATH.unshift File.expand_path("lib", __dir__)
5
+ require "harvest_time_off"
6
+
7
+ exit HarvestTimeOff::CLI.run(ARGV)
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HarvestTimeOff
4
+ VERSION = "0.2.0"
5
+ end
@@ -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: []