harvest-worklog 0.5.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: c1397fdec13d0a89c0023e926fa42043f701c351e0fbca487e40331c61bbfbcf
4
+ data.tar.gz: '09b9a725ea95df79e94cf204a1f12735c6db8e4d55d4a38f8442fc3f947f981f'
5
+ SHA512:
6
+ metadata.gz: 33f8bbfa7e4d6f4b98bcc014d6ba3cdb51193a2582a3fd3bed37e3d4051fb606440065f60076d9afd1efc9ab64aa4d6f14a4d6fdb4dd0ff92775419a34012d74
7
+ data.tar.gz: 32d59d73fc353d159e7e7a6c93373147f0a7756b4f3b0ba56b4bb877b2cd177000ee8898739eeb1813c3830640cb8a6ce57d1272ab9aa1a5f1bdec1146f3adf9
data/README.md ADDED
@@ -0,0 +1,89 @@
1
+ # Harvest Worklog
2
+
3
+ Harvest Worklog is a Ruby CLI and OMP plugin for personal Harvest work logs. It creates holiday-aware time-off entries, writes reviewed ordinary-work entries, and imports reviewed OMP Project Time sessions.
4
+
5
+ ## Credentials
6
+
7
+ 1. Open [Harvest ID Developers](https://id.getharvest.com/developers) and create a Personal Access Token for this local tool.
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
+ ## CLI
21
+
22
+ ```text
23
+ harvest-worklog time-off FROM TO --project NAME --task NAME [options]
24
+ harvest-worklog work-entry DATE --project NAME --task NAME --hours HOURS --notes NOTES [options]
25
+ ```
26
+
27
+ ### Time off
28
+
29
+ The `time-off` command creates one 7-hour entry for each Yukon business day in an inclusive range. It skips weekends and observed Yukon holidays by default.
30
+
31
+ ```bash
32
+ harvest-worklog time-off 2026-08-17 2026-08-28 \
33
+ --project 'Time Off - Marlen' \
34
+ --task 'Vacation / PTO' \
35
+ --notes 'regular time off' \
36
+ --dry-run
37
+ ```
38
+
39
+ 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.
40
+
41
+ A time-off block never creates weekend entries. When names are supplied, it resolves them from your active personal Harvest assignments, so a Project Manager role is not required.
42
+
43
+ ### Reviewed ordinary work
44
+
45
+ The `work-entry` command checks for existing or locked entries for its project, task, and date before creating a new duration entry. Use `--dry-run` to complete that preflight without a write. OMP Project Time uses the same path.
46
+
47
+ ## OMP settings
48
+
49
+ - `defaultHours`: hours per business day when a time-off tool call omits `hours`; defaults to `7`.
50
+ - `holidayRegions`: comma-separated Holidays regions; defaults to `ca_yt`.
51
+ - `command`: direct path to the `harvest-worklog` executable.
52
+ - `projectTimeMappings`: JSON mapping from OMP Project Time project names to Harvest project/task names.
53
+ - `projectTimeLogPath`: optional override for `~/.omp/project-time/time-log.json`.
54
+
55
+ ## OMP Project Time integration
56
+
57
+ Configure `projectTimeMappings` with the recorded OMP Project Time project name as the key:
58
+
59
+ ```json
60
+ {
61
+ "Harvest API": { "project": "Internal", "task": "Development" }
62
+ }
63
+ ```
64
+
65
+ `harvest_preview_project_time_entries` reads the configured time log, splits sessions across local dates, generates descriptions from the project and repository, and checks Harvest for existing or locked entries without writing. `harvest_record_project_time_entries` performs the same preflight then creates only new entries; OMP treats it as a write requiring approval. Unmapped sessions are reported and never written.
66
+
67
+ ## Migration from harvest-time-off
68
+
69
+ Version `0.5.0` is a clean identity cutover. Uninstall the prior `harvest-time-off` gem and OMP plugin, then install `harvest-worklog`; replace former top-level CLI commands with the `harvest-worklog time-off` and `harvest-worklog work-entry` subcommands. The old package, executables, and `workEntryCommand` setting are not retained.
70
+
71
+ ## Release
72
+
73
+ 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, install the released OMP plugin from GitHub, and verify it:
74
+
75
+ ```bash
76
+ ruby test_harvest_worklog.rb
77
+ npm run test:omp
78
+ harvest-worklog time-off 2026-08-17 2026-08-28 \
79
+ --project 'Time Off - Marlen' \
80
+ --task 'Vacation / PTO' \
81
+ --holiday-region ca_yt \
82
+ --dry-run
83
+ gem build harvest-worklog.gemspec
84
+ gem push harvest-worklog-<version>.gem
85
+ omp plugin uninstall harvest-time-off
86
+ omp plugin install --force github:klondikemarlen/harvest-worklog
87
+ omp plugin list
88
+ harvest-worklog --help
89
+ ```
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "harvest_worklog"
5
+
6
+ exit HarvestWorklog::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_worklog"
6
+
7
+ exit HarvestWorklog::CLI.run(ARGV)
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HarvestWorklog
4
+ VERSION = "0.5.0"
5
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HarvestWorklog
4
+ class WorkEntryCLI
5
+ EXISTING_ENTRY = 2
6
+ LOCKED_ENTRY = 3
7
+
8
+ def self.run(arguments, output: $stdout, error: $stderr, client: nil)
9
+ options = { dry_run: false }
10
+ parser = option_parser(options)
11
+ dates = parser.parse(arguments)
12
+ validate!(dates, options)
13
+
14
+ spent_date = Date.iso8601(dates.first)
15
+ client ||= Marlens::HarvestApiV2::Client.from_environment
16
+ project_id, task_id = if options[:project_id]
17
+ [options[:project_id], options[:task_id]]
18
+ else
19
+ TimeOffCLI.resolve_assignment(client, options[:project], options[:task])
20
+ end
21
+ existing_entries = client.request(
22
+ :get,
23
+ "/v2/time_entries",
24
+ params: { project_id:, task_id:, from: spent_date.iso8601, to: spent_date.iso8601 }
25
+ ).fetch("time_entries")
26
+
27
+ if existing_entries.any? { |entry| entry["is_locked"] }
28
+ output.puts "Locked existing Harvest entry on #{spent_date.iso8601}; skipped"
29
+ return LOCKED_ENTRY
30
+ end
31
+
32
+ if existing_entries.any?
33
+ output.puts "Existing Harvest entry on #{spent_date.iso8601}; skipped"
34
+ return EXISTING_ENTRY
35
+ end
36
+
37
+ if options[:dry_run]
38
+ output.puts "Would create #{spent_date.iso8601}: #{HarvestWorklog.display_hours(options[:hours])}h on #{options[:project]} / #{options[:task]}; #{options[:notes]}"
39
+ return 0
40
+ end
41
+
42
+ entry = client.create_time_entry(
43
+ project_id:,
44
+ task_id:,
45
+ spent_date:,
46
+ hours: options[:hours],
47
+ notes: options[:notes]
48
+ )
49
+ output.puts "Created #{spent_date.iso8601}: #{HarvestWorklog.display_hours(options[:hours])}h (entry ##{entry.fetch("id")})"
50
+ 0
51
+ rescue Error, Marlens::HarvestApiV2::Error, OptionParser::ParseError, Date::Error => e
52
+ error.puts "Error: #{e.message}"
53
+ error.puts parser if parser
54
+ 1
55
+ end
56
+
57
+ def self.option_parser(options)
58
+ OptionParser.new do |opts|
59
+ opts.banner = "Usage: harvest-worklog work-entry DATE --project NAME --task NAME --hours HOURS --notes NOTES [--dry-run]"
60
+ opts.on("--project NAME", "Harvest project name") { |value| options[:project] = value }
61
+ opts.on("--task NAME", "Harvest task name") { |value| options[:task] = value }
62
+ opts.on("--project-id ID", Integer, "Harvest project ID") { |value| options[:project_id] = value }
63
+ opts.on("--task-id ID", Integer, "Harvest task ID") { |value| options[:task_id] = value }
64
+ opts.on("--hours HOURS", Float, "Hours for this entry") { |value| options[:hours] = value }
65
+ opts.on("--notes NOTES", "Entry description") { |value| options[:notes] = value }
66
+ opts.on("--dry-run", "Check for existing entries without writing") { options[:dry_run] = true }
67
+ opts.on("-h", "--help", "Show this help") do
68
+ puts opts
69
+ exit 0
70
+ end
71
+ end
72
+ end
73
+
74
+ def self.validate!(dates, options)
75
+ raise Error, "DATE is required" unless dates.length == 1
76
+ raise Error, "--hours must be a positive finite number" unless options[:hours]&.positive? && options[:hours].finite?
77
+ raise Error, "--notes is required" unless options[:notes] && !options[:notes].empty?
78
+
79
+ names_provided = options[:project] || options[:task]
80
+ ids_provided = options[:project_id] || options[:task_id]
81
+ valid_names = options[:project] && options[:task]
82
+ valid_ids = options[:project_id] && options[:task_id]
83
+ raise Error, "supply --project and --task, or --project-id and --task-id" unless valid_names || valid_ids
84
+ raise Error, "do not combine project/task names with IDs" if names_provided && ids_provided
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "harvest_worklog/version"
4
+ require "business_time"
5
+ require "date"
6
+ require "holidays"
7
+ require "optparse"
8
+ require "marlens/harvest_api_v2"
9
+
10
+ module HarvestWorklog
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
+ command, *command_arguments = arguments
32
+ case command
33
+ when "time-off"
34
+ TimeOffCLI.run(command_arguments, output:, error:, client:)
35
+ when "work-entry"
36
+ WorkEntryCLI.run(command_arguments, output:, error:, client:)
37
+ when "-h", "--help"
38
+ output.puts usage
39
+ 0
40
+ else
41
+ error.puts "Error: choose time-off or work-entry"
42
+ error.puts usage
43
+ 1
44
+ end
45
+ end
46
+
47
+ def self.usage
48
+ <<~USAGE
49
+ Usage:
50
+ harvest-worklog time-off FROM TO --project NAME --task NAME [options]
51
+ harvest-worklog work-entry DATE --project NAME --task NAME --hours HOURS --notes NOTES [options]
52
+
53
+ Commands:
54
+ time-off Create one entry per local business day in a date range.
55
+ work-entry Create one reviewed ordinary-work entry.
56
+ USAGE
57
+ end
58
+ end
59
+
60
+ class TimeOffCLI
61
+ def self.run(arguments, output: $stdout, error: $stderr, client: nil)
62
+ options = { hours: 7.0, dry_run: false, holiday_regions: holiday_regions_from_environment }
63
+ parser = option_parser(options)
64
+ dates = parser.parse(arguments)
65
+ validate!(dates, options)
66
+
67
+ from = Date.iso8601(dates[0])
68
+ to = Date.iso8601(dates[1])
69
+ workdays = HarvestWorklog.dates_between(from, to, holiday_regions: options[:holiday_regions])
70
+ raise Error, "date range contains no days to enter" if workdays.empty?
71
+
72
+ if options[:dry_run]
73
+ print_dry_run(output, workdays, options)
74
+ return 0
75
+ end
76
+
77
+ client ||= Marlens::HarvestApiV2::Client.from_environment
78
+ project_id, task_id = if options[:project_id]
79
+ [options[:project_id], options[:task_id]]
80
+ else
81
+ resolve_assignment(client, options[:project], options[:task])
82
+ end
83
+
84
+ workdays.each do |date|
85
+ entry = client.create_time_entry(
86
+ project_id:,
87
+ task_id:,
88
+ spent_date: date,
89
+ hours: options[:hours],
90
+ notes: options[:notes]
91
+ )
92
+ output.puts "Created #{date.iso8601}: #{HarvestWorklog.display_hours(options[:hours])}h (entry ##{entry.fetch("id")})"
93
+ end
94
+ 0
95
+ rescue Error, Marlens::HarvestApiV2::Error, OptionParser::ParseError, Date::Error => e
96
+ error.puts "Error: #{e.message}"
97
+ error.puts parser if parser
98
+ 1
99
+ end
100
+
101
+ def self.option_parser(options)
102
+ OptionParser.new do |opts|
103
+ opts.banner = <<~USAGE
104
+ Usage: harvest-worklog time-off FROM TO --project NAME --task NAME [options]
105
+ harvest-worklog time-off FROM TO --project-id ID --task-id ID [options]
106
+
107
+ Creates one Harvest duration entry for each local business day from FROM through TO.
108
+ USAGE
109
+ opts.on("--project NAME", "Harvest project name") { |value| options[:project] = value }
110
+ opts.on("--task NAME", "Harvest task name") { |value| options[:task] = value }
111
+ opts.on("--project-id ID", Integer, "Harvest project ID") { |value| options[:project_id] = value }
112
+ opts.on("--task-id ID", Integer, "Harvest task ID") { |value| options[:task_id] = value }
113
+ opts.on("--hours HOURS", Float, "Hours per day (default: 7)") { |value| options[:hours] = value }
114
+ opts.on("--notes NOTES", "Optional note on every entry") { |value| options[:notes] = value }
115
+ opts.on("--holiday-region REGION", "Holidays region; repeat for each locality") { |value| options[:holiday_regions] << value.strip.downcase }
116
+ opts.on("--dry-run", "Print entries without calling Harvest") { options[:dry_run] = true }
117
+ opts.on("-h", "--help", "Show this help") do
118
+ puts opts
119
+ exit 0
120
+ end
121
+ end
122
+ end
123
+
124
+ def self.validate!(dates, options)
125
+ raise Error, "FROM and TO are required" unless dates.length == 2
126
+ raise Error, "--hours must be a positive finite number" unless options[:hours].positive? && options[:hours].finite?
127
+ raise Error, "--holiday-region or HARVEST_HOLIDAY_REGIONS is required" if options[:holiday_regions].empty?
128
+
129
+ names_provided = options[:project] || options[:task]
130
+ ids_provided = options[:project_id] || options[:task_id]
131
+ valid_names = options[:project] && options[:task]
132
+ valid_ids = options[:project_id] && options[:task_id]
133
+ raise Error, "supply --project and --task, or --project-id and --task-id" unless valid_names || valid_ids
134
+ raise Error, "do not combine project/task names with IDs" if names_provided && ids_provided
135
+ end
136
+
137
+ def self.holiday_regions_from_environment
138
+ ENV.fetch("HARVEST_HOLIDAY_REGIONS", "ca_yt").split(",").map { |region| region.strip.downcase }.reject(&:empty?)
139
+ end
140
+
141
+ def self.resolve_assignment(client, project_name, task_name)
142
+ # ponytail: one 2,000-item page covers personal assignments; follow cursor pagination if that ceiling is exceeded.
143
+ matches = client.active_personal_task_assignments.select do |assignment|
144
+ assignment.dig("project", "name")&.casecmp?(project_name) &&
145
+ assignment.dig("task", "name")&.casecmp?(task_name)
146
+ end
147
+
148
+ return [matches.first.dig("project", "id"), matches.first.dig("task", "id")] if matches.one?
149
+
150
+ qualifier = "project #{project_name.inspect} and task #{task_name.inspect}"
151
+ raise Error, "No active task assignment matches #{qualifier}. Pass --project-id and --task-id instead." if matches.empty?
152
+
153
+ raise Error, "Multiple active task assignments match #{qualifier}. Pass --project-id and --task-id instead."
154
+ end
155
+
156
+ def self.print_dry_run(output, dates, options)
157
+ project = options[:project] || "project ##{options[:project_id]}"
158
+ task = options[:task] || "task ##{options[:task_id]}"
159
+ notes = options[:notes] ? "; #{options[:notes]}" : ""
160
+ dates.each do |date|
161
+ output.puts "Would create #{date.iso8601}: #{HarvestWorklog.display_hours(options[:hours])}h on #{project} / #{task}#{notes}"
162
+ end
163
+ end
164
+ end
165
+ end
166
+
167
+ require "harvest_worklog/work_entry_cli"
metadata ADDED
@@ -0,0 +1,89 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: harvest-worklog
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.5.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.2'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '0.2'
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-worklog
58
+ extensions: []
59
+ extra_rdoc_files: []
60
+ files:
61
+ - README.md
62
+ - bin/harvest-worklog
63
+ - harvest-worklog.rb
64
+ - lib/harvest_worklog.rb
65
+ - lib/harvest_worklog/version.rb
66
+ - lib/harvest_worklog/work_entry_cli.rb
67
+ homepage: https://github.com/klondikemarlen/harvest-worklog
68
+ licenses:
69
+ - MIT
70
+ metadata:
71
+ source_code_uri: https://github.com/klondikemarlen/harvest-worklog
72
+ rdoc_options: []
73
+ require_paths:
74
+ - lib
75
+ required_ruby_version: !ruby/object:Gem::Requirement
76
+ requirements:
77
+ - - ">="
78
+ - !ruby/object:Gem::Version
79
+ version: '3.2'
80
+ required_rubygems_version: !ruby/object:Gem::Requirement
81
+ requirements:
82
+ - - ">="
83
+ - !ruby/object:Gem::Version
84
+ version: '0'
85
+ requirements: []
86
+ rubygems_version: 4.0.3
87
+ specification_version: 4
88
+ summary: Harvest work-log CLI and OMP integration.
89
+ test_files: []