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 +7 -0
- data/README.md +137 -0
- data/bin/omp-worklog +8 -0
- data/bin/release-plugin +5 -0
- data/bin/verify-plugin-release +25 -0
- data/lib/omp_worklog/version.rb +5 -0
- data/lib/omp_worklog/work_entry_cli.rb +100 -0
- data/lib/omp_worklog.rb +179 -0
- data/omp-worklog.rb +7 -0
- metadata +95 -0
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
data/bin/release-plugin
ADDED
|
@@ -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,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
|
data/lib/omp_worklog.rb
ADDED
|
@@ -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
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: []
|