omp-worklog 0.14.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: 5d3b75162007b035e355c30a7709bef9b63523da3f81c469d89fc9749738fc22
4
+ data.tar.gz: 4c2b9cfff9e693c218e367a567222a0621a02a5537c7bee52e9b1af743e96716
5
+ SHA512:
6
+ metadata.gz: 0eca9563cc7050108826e0b55996efa6b9300274e89358b0e62c59959c54b4d35630fbdbfda8910c9ca4f66db008901dda487c776821932bea1826b8bd1c5c80
7
+ data.tar.gz: 07ec72a2c85b3cfb91d5f536177a94a7307613ecda350c8bf5c15058e7dcf9f2db0a66127104d930f7a71191e8b37132f98438231e7953bb383e2737d79e149b
data/README.md ADDED
@@ -0,0 +1,137 @@
1
+ # OMP Worklog
2
+
3
+ OMP Worklog is a Project Time-driven Ruby CLI and OMP plugin that builds reviewable personal work logs from local evidence. Harvest is currently one manual destination: the only Harvest operations are explicitly requested time-off and ordinary-work writes.
4
+
5
+ ## Documentation
6
+
7
+ The repository-local [knowledge base](docs/README.md) defines the product direction, domain, architecture, decisions, and QA boundaries. It distinguishes the provider-neutral work-log direction from the current Harvest-specific adapters.
8
+
9
+ ## Credentials
10
+
11
+ 1. Open [Harvest ID Developers](https://id.getharvest.com/developers) and create a Personal Access Token for this local tool.
12
+ 2. Harvest displays the token and the available Harvest account IDs once. Copy the account ID for the account to track time in.
13
+ 3. Fill the ignored `.envrc` in this repository:
14
+
15
+ ```bash
16
+ export HARVEST_ACCESS_TOKEN="your-personal-access-token"
17
+ export HARVEST_ACCOUNT_ID="your-harvest-account-id"
18
+ ```
19
+
20
+ 4. Run `direnv allow` if you use direnv; otherwise run `source .envrc` in the shell that starts OMP or the CLI.
21
+
22
+ 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/).
23
+
24
+ ## CLI
25
+
26
+ ```text
27
+ omp-worklog time-off FROM TO --project NAME --task NAME [options]
28
+ omp-worklog time-off FROM TO --project-id ID --task-id ID [options]
29
+ omp-worklog work-entry DATE --project NAME --task NAME --hours HOURS --notes NOTES [options]
30
+ omp-worklog work-entry DATE --project-id ID --task-id ID --hours HOURS --notes NOTES [options]
31
+ ```
32
+
33
+ ### Time off
34
+
35
+ 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.
36
+
37
+ ```bash
38
+ omp-worklog time-off 2026-08-17 2026-08-28 \
39
+ --project 'Time Off - Marlen' \
40
+ --task 'Vacation / PTO' \
41
+ --notes 'regular time off' \
42
+ --dry-run
43
+ ```
44
+
45
+ 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.
46
+
47
+ 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.
48
+
49
+ The `omp_worklog_record_time_off` OMP tool accepts the same name pair or ID pair. Its optional `holidayRegions` array adds per-call regions to the configured defaults.
50
+
51
+ ### Reviewed ordinary work
52
+
53
+ 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.
54
+
55
+ ### Project Time timesheets
56
+
57
+ `/omp-worklog timesheet DATE` reads local `human_active` Project Time evidence and produces one review-only draft for every local project active on that day. It does not call Harvest or require Harvest credentials.
58
+
59
+ Use `--project PROJECT` only to restrict the draft to one exact local Project Time project:
60
+
61
+ ```text
62
+ /omp-worklog timesheet yesterday
63
+ /omp-worklog timesheet yesterday --project wrap
64
+ ```
65
+
66
+ A configured `projectTimeMappings` destination becomes the draft's Harvest `Project` and `Task`. Evidence with no mapping, an ambiguous work item, or an unassigned work item remains visible under its exact local project with `Harvest: Review destination`; choose the Harvest project and task before submitting. The command first renders date, local project, duration, and Harvest destination on separate lines in a single column, bounded to 22 physical lines. In interactive OMP sessions, a keyed `Generating work summary…` widget appears while the active model works and is replaced in place by the final summary or unavailable fallback. Summary evidence is ranked by cumulative workstream duration so structured repository and GitHub issue/PR context outweighs fragmented individual prompts. The final generated section contains one to three broad outcome bullets and a suggested Harvest note, remains bounded before display, and keeps the combined result within 30 logical lines. GitHub issue/PR numbers and Jira ticket IDs are extracted as facts, not inferred by the model. `harvest_preview_project_time_drafts` retains source IDs, source kinds, repository identities, intervals, work-item attribution, and narratives for detailed review. Nothing is submitted automatically.
67
+
68
+ #### Interactive Timesheet Specification
69
+
70
+ User stories:
71
+
72
+ - As a worker preparing a Harvest entry, I see one vertical column containing the date, exact local project, total duration, Harvest project/task or review warning, a short work summary, and a suggested note.
73
+ - As a worker waiting for the summary, I see one generation widget replaced in place by the final output.
74
+ - As a worker using `--project`, I see only that exact local project; unrelated projects and excluded records stay hidden.
75
+ - As a reviewer, I see broad duration-weighted work outcomes, generated prose marked for review, and only ticket identifiers grounded in selected evidence.
76
+ - As an auditor, I use the explicit preview tools when I need source IDs, intervals, repository metadata, raw narratives, or attribution details.
77
+
78
+ The observable contract is a deterministic block of at most 22 physical lines followed by a generated section of at most six lines, keeping the complete result within 30 logical lines. Every total stacks date, exact local project, aggregate duration, and configured Harvest project/task or `Review destination` on separate lines. The generated section uses the `AI-generated work summary (review before use)` heading, one to three concise outcome bullets derived from at most eight duration-ranked evidence records, and a clearly review-marked suggested Harvest note. Generated lines are capped at 100 characters with an ellipsis so one verbose model line cannot consume the screen. In interactive mode, one keyed OMP widget is updated from the generation state to final content; print and RPC modes retain a normal transcript message. The command remains review-only and never writes Harvest.
79
+
80
+ Type `/omp-worklog ` in OMP to discover `timesheet`; date aliases appear after selecting `timesheet `. After `--project`, Tab lists local human-active Project Time project names. Completion preserves the exact local project name.
81
+
82
+ ## OMP settings
83
+
84
+ - `defaultHours`: hours per business day when a time-off tool call omits `hours`; defaults to `7`.
85
+ - `holidayRegions`: comma-separated Holidays regions; defaults to `ca_yt`.
86
+ - `command`: direct path to the `omp-worklog` executable.
87
+ - `projectTimeLogPath`: optional path to Project Time v1 evidence; defaults to `~/.omp/project-time/time-log.sqlite`. A legacy versioned JSON snapshot is also supported.
88
+
89
+
90
+ ## OMP Project Time integration
91
+
92
+ Configure `projectTimeMappings` with the recorded OMP Project Time project name as the key:
93
+
94
+ ```json
95
+ {
96
+ "Harvest API": { "project": "Internal", "task": "Development" }
97
+ }
98
+ ```
99
+
100
+ OMP Worklog reads Project Time's persisted `omp-project-time/evidence` v1 entries. An absent, unsupported, or malformed ledger produces a no-write diagnostic; run a current Project Time session to create the SQLite ledger before retrying.
101
+
102
+ `omp_worklog_preview_project_time_drafts` produces deterministic, copyable Date/Project/Task/Duration blocks from all local `human_active` evidence in an inclusive date range. Mapped evidence uses its configured Harvest destination; unmapped, ambiguous, and unassigned evidence remains an explicit local-project draft. It preserves source entry IDs, intervals, source kind, repository identity, narratives, work items, and task-attribution provenance. Generated narratives are review evidence, never task identity or a factual Harvest note. The tool reads the local log only; it never calls or mutates Harvest.
103
+
104
+ ### Activity transforms
105
+
106
+ `omp_worklog_preview_project_time_transforms` emits deterministic JSON for local raw intervals. It accepts an inclusive date range and optional exact `repositoryId`, `project`, and `sourceKind` filters; each matching interval is split by local date and grouped by activity, with missing labels reported as `unlabelled`. It defaults to `human_active` and returns the effective top-level `sourceKind`; selecting `agent_turn_elapsed` requires an explicit source-kind request. Set `applyMappings` to create configured Harvest destinations or review-required local destinations for every included group. The output reports groups and draft entries; it never writes Harvest.
107
+
108
+
109
+
110
+ ## Migration from Harvest Worklog
111
+
112
+ Version `0.14.0` is a clean identity cutover. Uninstall the retired `harvest-worklog` gem and OMP plugin, then install `omp-worklog`; replace former `harvest-worklog` CLI and `/harvest-worklog` command invocations with `omp-worklog` and `/omp-worklog`. The retired package, executable, command, tools, module, and settings aliases are not retained.
113
+
114
+ ## Release
115
+
116
+ 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:
117
+
118
+ ```bash
119
+ ruby test_omp_worklog.rb
120
+ npm run test:omp
121
+ omp-worklog time-off 2026-08-17 2026-08-28 \
122
+ --project 'Time Off - Marlen' \
123
+ --task 'Vacation / PTO' \
124
+ --holiday-region ca_yt \
125
+ --dry-run
126
+ VERSION="$(ruby -Ilib -romp_worklog/version -e 'puts OmpWorklog::VERSION')"
127
+ gem build omp-worklog.gemspec
128
+ gem push "omp-worklog-${VERSION}.gem"
129
+ gem uninstall harvest-worklog --all --executables --ignore-dependencies
130
+ gem install --clear-sources --source https://rubygems.org omp-worklog --version "$VERSION" --no-document
131
+ omp plugin uninstall harvest-worklog
132
+ npm run release
133
+ npm run verify:release
134
+ omp-worklog --help
135
+ ```
136
+
137
+ `npm run release` owns the uninstall/force-install mutation. `npm run verify:release` never changes the installation: it checks the installed package version, resolved Git revision, and plugin path before running a deterministic slash-command smoke test against the installed plugin. It exits after verification. Existing OMP sessions retain startup-loaded extension code; restart them before optional manual autocomplete and slash-command QA.
data/bin/omp-worklog ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
5
+
6
+ require "omp_worklog"
7
+
8
+ exit OmpWorklog::CLI.run(ARGV)
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ omp plugin uninstall omp-worklog
5
+ omp plugin install --force github:klondikemarlen/omp-worklog
@@ -0,0 +1,25 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
5
+ cd "$root"
6
+ version="$(node -p 'require("./package.json").version')"
7
+ expected_revision="$(git rev-parse HEAD)"
8
+ plugin_root="$HOME/.omp/plugins"
9
+ installed_path="$plugin_root/node_modules/omp-worklog/package.json"
10
+ installed_version="$(node -p 'require(process.env.HOME + "/.omp/plugins/node_modules/omp-worklog/package.json").version')"
11
+ resolved_revision="$(node -e '
12
+ const lock = require("node:fs").readFileSync(process.argv[1], "utf8")
13
+ const match = /"omp-worklog"\s*:\s*\[\s*"omp-worklog@github:klondikemarlen\/omp-worklog#([0-9a-f]+)/.exec(lock)
14
+ if (!match) process.exit(1)
15
+ process.stdout.write(match[1])
16
+ ' "$plugin_root/bun.lock")" || {
17
+ printf 'Could not resolve the installed omp-worklog Git revision from %s.\n' "$plugin_root/bun.lock" >&2
18
+ exit 1
19
+ }
20
+ if [[ "$installed_version" != "$version" || "${expected_revision:0:${#resolved_revision}}" != "$resolved_revision" ]]; then
21
+ printf 'Expected installed omp-worklog@%s from Git revision %s; found omp-worklog@%s at %s resolved from Git revision %s.\n' "$version" "$expected_revision" "$installed_version" "$installed_path" "$resolved_revision" >&2
22
+ exit 1
23
+ fi
24
+ node --test "$plugin_root/node_modules/omp-worklog/test/"*.test.js
25
+ printf 'Verified omp-worklog@%s from Git revision %s at %s.\n' "$version" "$resolved_revision" "$installed_path"
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OmpWorklog
4
+ VERSION = "0.14.0"
5
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OmpWorklog
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, activity_entry: 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 options[:activity_entry]
33
+ activity_key = options[:notes].lines.first
34
+ unrelated_entries = existing_entries.reject { |entry| entry["notes"]&.lines&.first&.start_with?("OMP Project Time activity: ") }
35
+ if unrelated_entries.any? || existing_entries.any? { |entry| entry["notes"]&.lines&.first == activity_key }
36
+ output.puts "Existing Harvest entry on #{spent_date.iso8601}; skipped"
37
+ return EXISTING_ENTRY
38
+ end
39
+ elsif existing_entries.any?
40
+ output.puts "Existing Harvest entry on #{spent_date.iso8601}; skipped"
41
+ return EXISTING_ENTRY
42
+ end
43
+
44
+ if options[:dry_run]
45
+ project = options[:project] || "project ##{options[:project_id]}"
46
+ task = options[:task] || "task ##{options[:task_id]}"
47
+ output.puts "Would create #{spent_date.iso8601}: #{OmpWorklog.display_hours(options[:hours])}h on #{project} / #{task}; #{options[:notes]}"
48
+ return 0
49
+ end
50
+
51
+ entry = client.create_time_entry(
52
+ project_id:,
53
+ task_id:,
54
+ spent_date:,
55
+ hours: options[:hours],
56
+ notes: options[:notes]
57
+ )
58
+ output.puts "Created #{spent_date.iso8601}: #{OmpWorklog.display_hours(options[:hours])}h (entry ##{entry.fetch("id")})"
59
+ 0
60
+ rescue Error, Marlens::HarvestApiV2::Error, OptionParser::ParseError, Date::Error => e
61
+ error.puts "Error: #{e.message}"
62
+ error.puts parser if parser
63
+ 1
64
+ end
65
+
66
+ def self.option_parser(options)
67
+ OptionParser.new do |opts|
68
+ opts.banner = <<~USAGE
69
+ Usage: omp-worklog work-entry DATE --project NAME --task NAME --hours HOURS --notes NOTES [options]
70
+ omp-worklog work-entry DATE --project-id ID --task-id ID --hours HOURS --notes NOTES [options]
71
+ USAGE
72
+ opts.on("--project NAME", "Harvest project name") { |value| options[:project] = value }
73
+ opts.on("--task NAME", "Harvest task name") { |value| options[:task] = value }
74
+ opts.on("--project-id ID", Integer, "Harvest project ID") { |value| options[:project_id] = value }
75
+ opts.on("--task-id ID", Integer, "Harvest task ID") { |value| options[:task_id] = value }
76
+ opts.on("--hours HOURS", Float, "Hours for this entry") { |value| options[:hours] = value }
77
+ opts.on("--notes NOTES", "Entry description") { |value| options[:notes] = value }
78
+ opts.on("--dry-run", "Check for existing entries without writing") { options[:dry_run] = true }
79
+ opts.on("--activity-entry", "Allow distinct OMP Project Time activity entries on the same date, project, and task") { options[:activity_entry] = true }
80
+ opts.on("-h", "--help", "Show this help") do
81
+ puts opts
82
+ exit 0
83
+ end
84
+ end
85
+ end
86
+
87
+ def self.validate!(dates, options)
88
+ raise Error, "DATE is required" unless dates.length == 1
89
+ raise Error, "--hours must be a positive finite number" unless options[:hours]&.positive? && options[:hours].finite?
90
+ raise Error, "--notes is required" unless options[:notes] && !options[:notes].strip.empty?
91
+
92
+ names_provided = options[:project] || options[:task]
93
+ ids_provided = options[:project_id] || options[:task_id]
94
+ valid_names = options[:project] && !options[:project].strip.empty? && options[:task] && !options[:task].strip.empty?
95
+ valid_ids = options[:project_id]&.positive? && options[:task_id]&.positive?
96
+ raise Error, "supply --project and --task, or --project-id and --task-id" unless valid_names || valid_ids
97
+ raise Error, "do not combine project/task names with IDs" if names_provided && ids_provided
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,179 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "omp_worklog/version"
4
+ require "business_time"
5
+ require "date"
6
+ require "json"
7
+ require "holidays"
8
+ require "optparse"
9
+ require "marlens/harvest_api_v2"
10
+
11
+ module OmpWorklog
12
+ module_function
13
+
14
+ def dates_between(from, to, holiday_regions:)
15
+ raise Error, "end date must not be before start date" if to < from
16
+ raise Error, "at least one holiday region is required" if holiday_regions.empty?
17
+
18
+ holidays = Holidays.between(from, to, *(holiday_regions.map { |region| region.downcase.to_sym } + [:observed])).map { |holiday| holiday[:date] }
19
+ (from..to).select { |date| date.workday?(holidays:) }
20
+ rescue Holidays::InvalidRegion
21
+ raise Error, "invalid holiday region: #{holiday_regions.join(", ")}"
22
+ end
23
+
24
+ def display_hours(hours)
25
+ format("%g", hours)
26
+ end
27
+
28
+ class Error < StandardError; end
29
+
30
+ class CLI
31
+ def self.run(arguments, output: $stdout, error: $stderr, client: nil)
32
+ command, *command_arguments = arguments
33
+ case command
34
+ when "time-off"
35
+ TimeOffCLI.run(command_arguments, output:, error:, client:)
36
+ when "work-entry"
37
+ WorkEntryCLI.run(command_arguments, output:, error:, client:)
38
+ when "-h", "--help"
39
+ output.puts usage
40
+ 0
41
+ else
42
+ error.puts "Error: choose time-off or work-entry"
43
+ error.puts usage
44
+ 1
45
+ end
46
+ end
47
+
48
+ def self.usage
49
+ <<~USAGE
50
+ Usage:
51
+ omp-worklog time-off FROM TO --project NAME --task NAME [options]
52
+ omp-worklog time-off FROM TO --project-id ID --task-id ID [options]
53
+ omp-worklog work-entry DATE --project NAME --task NAME --hours HOURS --notes NOTES [options]
54
+ omp-worklog work-entry DATE --project-id ID --task-id ID --hours HOURS --notes NOTES [options]
55
+
56
+ Commands:
57
+ time-off Create one entry per local business day in a date range.
58
+ work-entry Create one reviewed ordinary-work entry.
59
+ USAGE
60
+ end
61
+ end
62
+
63
+ class TimeOffCLI
64
+ def self.run(arguments, output: $stdout, error: $stderr, client: nil)
65
+ options = { hours: 7.0, dry_run: false, holiday_regions: holiday_regions_from_environment }
66
+ parser = option_parser(options)
67
+ dates = parser.parse(arguments)
68
+ normalize_holiday_regions!(options[:holiday_regions])
69
+ validate!(dates, options)
70
+
71
+ from = Date.iso8601(dates[0])
72
+ to = Date.iso8601(dates[1])
73
+ workdays = OmpWorklog.dates_between(from, to, holiday_regions: options[:holiday_regions])
74
+ raise Error, "date range contains no days to enter" if workdays.empty?
75
+
76
+ if options[:dry_run]
77
+ print_dry_run(output, workdays, options)
78
+ return 0
79
+ end
80
+
81
+ client ||= Marlens::HarvestApiV2::Client.from_environment
82
+ project_id, task_id = if options[:project_id]
83
+ [options[:project_id], options[:task_id]]
84
+ else
85
+ resolve_assignment(client, options[:project], options[:task])
86
+ end
87
+
88
+ workdays.each do |date|
89
+ entry = client.create_time_entry(
90
+ project_id:,
91
+ task_id:,
92
+ spent_date: date,
93
+ hours: options[:hours],
94
+ notes: options[:notes]
95
+ )
96
+ output.puts "Created #{date.iso8601}: #{OmpWorklog.display_hours(options[:hours])}h (entry ##{entry.fetch("id")})"
97
+ end
98
+ 0
99
+ rescue Error, Marlens::HarvestApiV2::Error, OptionParser::ParseError, Date::Error => e
100
+ error.puts "Error: #{e.message}"
101
+ error.puts parser if parser
102
+ 1
103
+ end
104
+
105
+ def self.option_parser(options)
106
+ OptionParser.new do |opts|
107
+ opts.banner = <<~USAGE
108
+ Usage: omp-worklog time-off FROM TO --project NAME --task NAME [options]
109
+ omp-worklog time-off FROM TO --project-id ID --task-id ID [options]
110
+
111
+ Creates one Harvest duration entry for each local business day from FROM through TO.
112
+ USAGE
113
+ opts.on("--project NAME", "Harvest project name") { |value| options[:project] = value }
114
+ opts.on("--task NAME", "Harvest task name") { |value| options[:task] = value }
115
+ opts.on("--project-id ID", Integer, "Harvest project ID") { |value| options[:project_id] = value }
116
+ opts.on("--task-id ID", Integer, "Harvest task ID") { |value| options[:task_id] = value }
117
+ opts.on("--hours HOURS", Float, "Hours per day (default: 7)") { |value| options[:hours] = value }
118
+ opts.on("--notes NOTES", "Optional note on every entry") { |value| options[:notes] = value }
119
+ opts.on("--holiday-region REGION", "Holidays region; repeat for each locality") { |value| options[:holiday_regions] << value.strip.downcase }
120
+ opts.on("--dry-run", "Print entries without calling Harvest") { options[:dry_run] = true }
121
+ opts.on("-h", "--help", "Show this help") do
122
+ puts opts
123
+ exit 0
124
+ end
125
+ end
126
+ end
127
+
128
+ def self.validate!(dates, options)
129
+ raise Error, "FROM and TO are required" unless dates.length == 2
130
+ raise Error, "--hours must be a positive finite number" unless options[:hours].positive? && options[:hours].finite?
131
+ raise Error, "--holiday-region or HARVEST_HOLIDAY_REGIONS is required" if options[:holiday_regions].empty?
132
+ raise Error, "--notes must not be blank" if options[:notes]&.strip&.empty?
133
+
134
+ names_provided = options[:project] || options[:task]
135
+ ids_provided = options[:project_id] || options[:task_id]
136
+ valid_names = options[:project] && !options[:project].strip.empty? && options[:task] && !options[:task].strip.empty?
137
+ valid_ids = options[:project_id]&.positive? && options[:task_id]&.positive?
138
+ raise Error, "supply --project and --task, or --project-id and --task-id" unless valid_names || valid_ids
139
+ raise Error, "do not combine project/task names with IDs" if names_provided && ids_provided
140
+ end
141
+
142
+ def self.holiday_regions_from_environment
143
+ ENV.fetch("HARVEST_HOLIDAY_REGIONS", "ca_yt").split(",").map { |region| region.strip.downcase }.reject(&:empty?)
144
+ end
145
+
146
+ def self.normalize_holiday_regions!(regions)
147
+ regions.map! { |region| region.strip.downcase }
148
+ regions.reject!(&:empty?)
149
+ regions.uniq!
150
+ regions
151
+ end
152
+
153
+ def self.resolve_assignment(client, project_name, task_name)
154
+ # ponytail: one 2,000-item page covers personal assignments; follow cursor pagination if that ceiling is exceeded.
155
+ matches = client.active_personal_task_assignments.select do |assignment|
156
+ assignment.dig("project", "name")&.casecmp?(project_name) &&
157
+ assignment.dig("task", "name")&.casecmp?(task_name)
158
+ end
159
+
160
+ return [matches.first.dig("project", "id"), matches.first.dig("task", "id")] if matches.one?
161
+
162
+ qualifier = "project #{project_name.inspect} and task #{task_name.inspect}"
163
+ raise Error, "No active task assignment matches #{qualifier}. Pass --project-id and --task-id instead." if matches.empty?
164
+
165
+ raise Error, "Multiple active task assignments match #{qualifier}. Pass --project-id and --task-id instead."
166
+ end
167
+
168
+ def self.print_dry_run(output, dates, options)
169
+ project = options[:project] || "project ##{options[:project_id]}"
170
+ task = options[:task] || "task ##{options[:task_id]}"
171
+ notes = options[:notes] ? "; #{options[:notes]}" : ""
172
+ dates.each do |date|
173
+ output.puts "Would create #{date.iso8601}: #{OmpWorklog.display_hours(options[:hours])}h on #{project} / #{task}#{notes}"
174
+ end
175
+ end
176
+ end
177
+ end
178
+
179
+ require "omp_worklog/work_entry_cli"
data/omp-worklog.rb ADDED
@@ -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 "omp_worklog"
6
+
7
+ exit OmpWorklog::CLI.run(ARGV)
metadata ADDED
@@ -0,0 +1,95 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: omp-worklog
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.14.0
5
+ platform: ruby
6
+ authors:
7
+ - Marlen Brunner
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-10-06 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: marlens-harvest-api-v2
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '0.2'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '0.2'
27
+ - !ruby/object:Gem::Dependency
28
+ name: business_time
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '0.13'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '0.13'
41
+ - !ruby/object:Gem::Dependency
42
+ name: holidays
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '9.2'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '9.2'
55
+ description:
56
+ email:
57
+ - klondikemarlen@gmail.com
58
+ executables:
59
+ - omp-worklog
60
+ extensions: []
61
+ extra_rdoc_files: []
62
+ files:
63
+ - README.md
64
+ - bin/omp-worklog
65
+ - bin/release-plugin
66
+ - bin/verify-plugin-release
67
+ - lib/omp_worklog.rb
68
+ - lib/omp_worklog/version.rb
69
+ - lib/omp_worklog/work_entry_cli.rb
70
+ - omp-worklog.rb
71
+ homepage: https://github.com/klondikemarlen/omp-worklog
72
+ licenses:
73
+ - MIT
74
+ metadata:
75
+ source_code_uri: https://github.com/klondikemarlen/omp-worklog
76
+ post_install_message:
77
+ rdoc_options: []
78
+ require_paths:
79
+ - lib
80
+ required_ruby_version: !ruby/object:Gem::Requirement
81
+ requirements:
82
+ - - ">="
83
+ - !ruby/object:Gem::Version
84
+ version: '3.2'
85
+ required_rubygems_version: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: '0'
90
+ requirements: []
91
+ rubygems_version: 3.5.22
92
+ signing_key:
93
+ specification_version: 4
94
+ summary: OMP Project Time work logs with an optional manual Harvest destination.
95
+ test_files: []