harvest-worklog 0.13.11 → 0.13.12
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 +4 -4
- data/README.md +13 -43
- data/lib/harvest_worklog/version.rb +1 -1
- data/lib/harvest_worklog.rb +1 -21
- metadata +2 -6
- data/lib/harvest_worklog/aggregate_cli.rb +0 -101
- data/lib/harvest_worklog/mapping_data_cli.rb +0 -37
- data/lib/harvest_worklog/reconcile_cli.rb +0 -116
- data/lib/harvest_worklog/timesheet_cli.rb +0 -82
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 694f2a596182beb6a240d9917c456acdf9e329661383db4e1c5159ea19c58663
|
|
4
|
+
data.tar.gz: bf66d8629771e93f21934cdcbb99e7f7d7cac31ddfccf098da22d38df3fe1997
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 280ca40bcb6493adb43a9992c36b4fb1810443ed1af1a0bdc96560441a5ec8c5966d7b6af3d34bbdfd398fe9add4bbb784057dcf8a7b9eeb1e64fbfd6b2dc9f7
|
|
7
|
+
data.tar.gz: 74ad6fa40e341387c9946f1dbe1fcff057a7075aca57654d4a2ff39a748eb7126375488046e0d52c6388b903bffbf53fb7597ab684641d92fab02c0158b81ae4
|
data/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Harvest Worklog
|
|
2
2
|
|
|
3
|
-
Harvest Worklog is a Ruby CLI and OMP plugin that
|
|
3
|
+
Harvest Worklog is a Project Time-driven Ruby CLI and OMP plugin that builds reviewable personal work timesheets from local evidence. Harvest remains a manual destination: the only Harvest operations are explicitly requested time-off and ordinary-work writes.
|
|
4
4
|
|
|
5
5
|
## Credentials
|
|
6
6
|
|
|
@@ -24,9 +24,6 @@ harvest-worklog time-off FROM TO --project NAME --task NAME [options]
|
|
|
24
24
|
harvest-worklog time-off FROM TO --project-id ID --task-id ID [options]
|
|
25
25
|
harvest-worklog work-entry DATE --project NAME --task NAME --hours HOURS --notes NOTES [options]
|
|
26
26
|
harvest-worklog work-entry DATE --project-id ID --task-id ID --hours HOURS --notes NOTES [options]
|
|
27
|
-
harvest-worklog aggregate FROM TO [--project NAME] [--task NAME]
|
|
28
|
-
harvest-worklog timesheet DATE --project NAME [--task NAME]
|
|
29
|
-
harvest-worklog reconcile DATE --project PROJECT --harvest-project NAME [--task NAME]
|
|
30
27
|
```
|
|
31
28
|
|
|
32
29
|
### Time off
|
|
@@ -49,40 +46,29 @@ The `harvest_record_time_off` OMP tool accepts the same name pair or ID pair. It
|
|
|
49
46
|
|
|
50
47
|
### Reviewed ordinary work
|
|
51
48
|
|
|
52
|
-
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.
|
|
49
|
+
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.
|
|
53
50
|
|
|
54
|
-
###
|
|
51
|
+
### Project Time timesheets
|
|
55
52
|
|
|
56
|
-
|
|
53
|
+
`/harvest-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.
|
|
57
54
|
|
|
58
|
-
|
|
59
|
-
harvest-worklog aggregate 2026-07-17 2026-07-19 \
|
|
60
|
-
--project WRAP \
|
|
61
|
-
--task Programming
|
|
62
|
-
```
|
|
63
|
-
|
|
64
|
-
The `harvest_time_aggregates` OMP tool exposes the same optional filters and is approval-gated as a read.
|
|
65
|
-
|
|
66
|
-
### Timesheets
|
|
55
|
+
Use `--project PROJECT` only to restrict the draft to one exact local Project Time project:
|
|
67
56
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
harvest-worklog timesheet today --project WRAP
|
|
57
|
+
```text
|
|
58
|
+
/harvest-worklog timesheet yesterday
|
|
59
|
+
/harvest-worklog timesheet yesterday --project wrap
|
|
72
60
|
```
|
|
73
61
|
|
|
74
|
-
|
|
75
|
-
When a Project Time log has no narrative text, the draft does not turn activity labels into Harvest notes; it asks the user to add factual detail.
|
|
76
|
-
`/project-time history` reports all logged dates, so its totals can be larger than the selected day.
|
|
62
|
+
A configured `projectTimeMappings` destination becomes the draft's `Project` and `Task`. Evidence with no mapping, an ambiguous work item, or an unassigned work item remains visible under its local project with `Task: Review destination`; choose the Harvest project and task before submitting. Every source entry includes its ID, source kind, repository identity, interval, task-attribution provenance, and optional narrative. Nothing is submitted automatically.
|
|
77
63
|
|
|
78
|
-
Type `/harvest-worklog ` in OMP to discover `timesheet`; date aliases appear
|
|
64
|
+
Type `/harvest-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.
|
|
79
65
|
|
|
80
66
|
## OMP settings
|
|
81
67
|
|
|
82
68
|
- `defaultHours`: hours per business day when a time-off tool call omits `hours`; defaults to `7`.
|
|
83
69
|
- `holidayRegions`: comma-separated Holidays regions; defaults to `ca_yt`.
|
|
84
70
|
- `command`: direct path to the `harvest-worklog` executable.
|
|
85
|
-
- `projectTimeMappings`: JSON mapping from OMP Project Time project names to Harvest project/task names.
|
|
71
|
+
- `projectTimeMappings`: optional JSON mapping from OMP Project Time project names to Harvest project/task names. Unmapped evidence remains a review-required local draft.
|
|
86
72
|
- `projectTimeLogPath`: optional override for `~/.omp/project-time/time-log.json`.
|
|
87
73
|
|
|
88
74
|
## OMP Project Time integration
|
|
@@ -97,29 +83,13 @@ Configure `projectTimeMappings` with the recorded OMP Project Time project name
|
|
|
97
83
|
|
|
98
84
|
Harvest Worklog reads only the documented `omp-project-time/evidence` format at version `1`. An absent, unsupported, or malformed version produces a no-write diagnostic; run a current Project Time session to persist the v1 ledger format before retrying.
|
|
99
85
|
|
|
100
|
-
`
|
|
101
|
-
|
|
102
|
-
`harvest_preview_project_time_drafts` produces one deterministic, copyable Date/Project/Task/Duration block per mapped local day and Harvest destination from `human_active` evidence. It preserves source entry IDs, intervals, source kind, repository identity, narratives, work items, and task-attribution provenance. Explicit and carried-forward evidence can use the existing project-to-Harvest mapping; unassigned and ambiguous evidence stays visible but not submittable. 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.
|
|
86
|
+
`harvest_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
87
|
|
|
104
88
|
### Activity transforms
|
|
105
89
|
|
|
106
|
-
`harvest_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
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
## Daily reconciliation
|
|
110
|
-
|
|
111
|
-
`reconcile` compares one local Project Time project against the manual Harvest total for one date. It reads only local `human_active` intervals, reports the raw duration, non-overlapping union, concurrent overlap, and both Harvest-minus-local deltas. It never creates or changes Harvest entries: manual Harvest is the benchmark.
|
|
112
|
-
|
|
113
|
-
```bash
|
|
114
|
-
harvest-worklog reconcile 2026-07-21 \
|
|
115
|
-
--project wrap \
|
|
116
|
-
--harvest-project WRAP \
|
|
117
|
-
--task Programming
|
|
118
|
-
```
|
|
90
|
+
`harvest_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, draft entries, and excluded rows; it never writes Harvest.
|
|
119
91
|
|
|
120
|
-
Empty local or Harvest data is reported as zero, so this command is suitable for daily capture checks while manual tracking remains authoritative. It never decides or applies a Harvest delta.
|
|
121
92
|
|
|
122
|
-
Manual Harvest entries remain separate from inferred Project Time durations. Review the timesheet and reconciliation output, then choose the actual Harvest entry yourself; do not sum local evidence and manually recorded Harvest time as if they were independent entries.
|
|
123
93
|
|
|
124
94
|
## Migration from harvest-time-off
|
|
125
95
|
|
data/lib/harvest_worklog.rb
CHANGED
|
@@ -35,19 +35,11 @@ module HarvestWorklog
|
|
|
35
35
|
TimeOffCLI.run(command_arguments, output:, error:, client:)
|
|
36
36
|
when "work-entry"
|
|
37
37
|
WorkEntryCLI.run(command_arguments, output:, error:, client:)
|
|
38
|
-
when "aggregate"
|
|
39
|
-
AggregateCLI.run(command_arguments, output:, error:, client:)
|
|
40
|
-
when "timesheet"
|
|
41
|
-
TimesheetCLI.run(command_arguments, output:, error:, client:)
|
|
42
|
-
when "reconcile"
|
|
43
|
-
ReconcileCLI.run(command_arguments, output:, error:, client:)
|
|
44
|
-
when "mapping-data"
|
|
45
|
-
MappingDataCLI.run(command_arguments, output:, error:, client:)
|
|
46
38
|
when "-h", "--help"
|
|
47
39
|
output.puts usage
|
|
48
40
|
0
|
|
49
41
|
else
|
|
50
|
-
error.puts "Error: choose time-off
|
|
42
|
+
error.puts "Error: choose time-off or work-entry"
|
|
51
43
|
error.puts usage
|
|
52
44
|
1
|
|
53
45
|
end
|
|
@@ -60,18 +52,10 @@ module HarvestWorklog
|
|
|
60
52
|
harvest-worklog time-off FROM TO --project-id ID --task-id ID [options]
|
|
61
53
|
harvest-worklog work-entry DATE --project NAME --task NAME --hours HOURS --notes NOTES [options]
|
|
62
54
|
harvest-worklog work-entry DATE --project-id ID --task-id ID --hours HOURS --notes NOTES [options]
|
|
63
|
-
harvest-worklog aggregate FROM TO [--project NAME] [--task NAME]
|
|
64
|
-
harvest-worklog timesheet DATE --project NAME [--task NAME]
|
|
65
|
-
harvest-worklog reconcile DATE --project PROJECT --harvest-project NAME [--task NAME]
|
|
66
|
-
harvest-worklog mapping-data FROM TO
|
|
67
55
|
|
|
68
56
|
Commands:
|
|
69
57
|
time-off Create one entry per local business day in a date range.
|
|
70
58
|
work-entry Create one reviewed ordinary-work entry.
|
|
71
|
-
aggregate Read time-entry totals without writing Harvest records.
|
|
72
|
-
timesheet Read a compact daily project timesheet without writing Harvest records.
|
|
73
|
-
reconcile Compare local Project Time with manual Harvest without writing.
|
|
74
|
-
mapping-data Read assigned Harvest destinations and historical entries without writing.
|
|
75
59
|
USAGE
|
|
76
60
|
end
|
|
77
61
|
end
|
|
@@ -193,7 +177,3 @@ module HarvestWorklog
|
|
|
193
177
|
end
|
|
194
178
|
|
|
195
179
|
require "harvest_worklog/work_entry_cli"
|
|
196
|
-
require "harvest_worklog/aggregate_cli"
|
|
197
|
-
require "harvest_worklog/timesheet_cli"
|
|
198
|
-
require "harvest_worklog/reconcile_cli"
|
|
199
|
-
require "harvest_worklog/mapping_data_cli"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: harvest-worklog
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.13.
|
|
4
|
+
version: 0.13.12
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Marlen Brunner
|
|
@@ -66,10 +66,6 @@ files:
|
|
|
66
66
|
- bin/verify-plugin-release
|
|
67
67
|
- harvest-worklog.rb
|
|
68
68
|
- lib/harvest_worklog.rb
|
|
69
|
-
- lib/harvest_worklog/aggregate_cli.rb
|
|
70
|
-
- lib/harvest_worklog/mapping_data_cli.rb
|
|
71
|
-
- lib/harvest_worklog/reconcile_cli.rb
|
|
72
|
-
- lib/harvest_worklog/timesheet_cli.rb
|
|
73
69
|
- lib/harvest_worklog/version.rb
|
|
74
70
|
- lib/harvest_worklog/work_entry_cli.rb
|
|
75
71
|
homepage: https://github.com/klondikemarlen/harvest-worklog
|
|
@@ -95,5 +91,5 @@ requirements: []
|
|
|
95
91
|
rubygems_version: 3.5.22
|
|
96
92
|
signing_key:
|
|
97
93
|
specification_version: 4
|
|
98
|
-
summary:
|
|
94
|
+
summary: Project Time work-timesheet drafts with manual Harvest writes.
|
|
99
95
|
test_files: []
|
|
@@ -1,101 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module HarvestWorklog
|
|
4
|
-
class AggregateCLI
|
|
5
|
-
PER_PAGE = 100
|
|
6
|
-
|
|
7
|
-
def self.run(arguments, output: $stdout, error: $stderr, client: nil)
|
|
8
|
-
options = {}
|
|
9
|
-
parser = option_parser(options)
|
|
10
|
-
dates = parser.parse(arguments)
|
|
11
|
-
validate!(dates, options)
|
|
12
|
-
|
|
13
|
-
from = Date.iso8601(dates[0])
|
|
14
|
-
to = Date.iso8601(dates[1])
|
|
15
|
-
raise Error, "end date must not be before start date" if to < from
|
|
16
|
-
|
|
17
|
-
client ||= Marlens::HarvestApiV2::Client.from_environment
|
|
18
|
-
entries = fetch_entries(client, from:, to:).select { |entry| matches?(entry, options) }
|
|
19
|
-
print_summary(output, entries, from:, to:)
|
|
20
|
-
0
|
|
21
|
-
rescue Error, Marlens::HarvestApiV2::Error, OptionParser::ParseError, Date::Error => e
|
|
22
|
-
error.puts "Error: #{e.message}"
|
|
23
|
-
error.puts parser if parser
|
|
24
|
-
1
|
|
25
|
-
end
|
|
26
|
-
|
|
27
|
-
def self.option_parser(options)
|
|
28
|
-
OptionParser.new do |opts|
|
|
29
|
-
opts.banner = "Usage: harvest-worklog aggregate FROM TO [--project NAME] [--task NAME]"
|
|
30
|
-
opts.on("--project NAME", "Filter by Harvest project name") { |value| options[:project] = value }
|
|
31
|
-
opts.on("--task NAME", "Filter by Harvest task name") { |value| options[:task] = value }
|
|
32
|
-
opts.on("-h", "--help", "Show this help") do
|
|
33
|
-
puts opts
|
|
34
|
-
exit 0
|
|
35
|
-
end
|
|
36
|
-
end
|
|
37
|
-
end
|
|
38
|
-
|
|
39
|
-
def self.validate!(dates, options = {})
|
|
40
|
-
raise Error, "FROM and TO are required" unless dates.length == 2
|
|
41
|
-
raise Error, "--project must not be blank" if options[:project]&.strip&.empty?
|
|
42
|
-
raise Error, "--task must not be blank" if options[:task]&.strip&.empty?
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
def self.fetch_entries(client, from:, to:, user_id: nil)
|
|
46
|
-
entries = []
|
|
47
|
-
page = 1
|
|
48
|
-
loop do
|
|
49
|
-
params = { from: from.iso8601, to: to.iso8601, page:, per_page: PER_PAGE }
|
|
50
|
-
params[:user_id] = user_id if user_id
|
|
51
|
-
response = client.request(
|
|
52
|
-
:get,
|
|
53
|
-
"/v2/time_entries",
|
|
54
|
-
params:
|
|
55
|
-
)
|
|
56
|
-
entries.concat(response.fetch("time_entries"))
|
|
57
|
-
page = response["next_page"]
|
|
58
|
-
break if page.nil?
|
|
59
|
-
end
|
|
60
|
-
entries
|
|
61
|
-
end
|
|
62
|
-
|
|
63
|
-
def self.matches?(entry, options)
|
|
64
|
-
name_matches?(entry.dig("project", "name"), options[:project]) &&
|
|
65
|
-
name_matches?(entry.dig("task", "name"), options[:task])
|
|
66
|
-
end
|
|
67
|
-
|
|
68
|
-
def self.name_matches?(actual, expected)
|
|
69
|
-
expected.nil? || actual&.casecmp?(expected)
|
|
70
|
-
end
|
|
71
|
-
|
|
72
|
-
def self.print_summary(output, entries, from:, to:)
|
|
73
|
-
output.puts "#{entries.length} #{entry_label(entries.length)}, #{HarvestWorklog.display_hours(total_hours(entries))}h from #{from.iso8601} through #{to.iso8601}"
|
|
74
|
-
output.puts "By date:"
|
|
75
|
-
(from..to).each do |date|
|
|
76
|
-
date_entries = entries.select { |entry| entry.fetch("spent_date") == date.iso8601 }
|
|
77
|
-
output.puts " #{date.iso8601}: #{date_entries.length} #{entry_label(date_entries.length)}, #{HarvestWorklog.display_hours(total_hours(date_entries))}h"
|
|
78
|
-
end
|
|
79
|
-
output.puts "By project/task:"
|
|
80
|
-
|
|
81
|
-
groups = entries.group_by { |entry| [entry.dig("project", "name"), entry.dig("task", "name")] }
|
|
82
|
-
if groups.empty?
|
|
83
|
-
output.puts " none"
|
|
84
|
-
return
|
|
85
|
-
end
|
|
86
|
-
|
|
87
|
-
groups.keys.sort_by { |project, task| [project.downcase, task.downcase] }.each do |project, task|
|
|
88
|
-
group = groups.fetch([project, task])
|
|
89
|
-
output.puts " #{project} / #{task}: #{group.length} #{entry_label(group.length)}, #{HarvestWorklog.display_hours(total_hours(group))}h"
|
|
90
|
-
end
|
|
91
|
-
end
|
|
92
|
-
|
|
93
|
-
def self.total_hours(entries)
|
|
94
|
-
entries.sum { |entry| Float(entry.fetch("hours")) }
|
|
95
|
-
end
|
|
96
|
-
|
|
97
|
-
def self.entry_label(count)
|
|
98
|
-
count == 1 ? "entry" : "entries"
|
|
99
|
-
end
|
|
100
|
-
end
|
|
101
|
-
end
|
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "json"
|
|
4
|
-
|
|
5
|
-
module HarvestWorklog
|
|
6
|
-
class MappingDataCLI
|
|
7
|
-
def self.run(arguments, output: $stdout, error: $stderr, client: nil)
|
|
8
|
-
from, to = arguments
|
|
9
|
-
raise Error, "FROM and TO are required" unless arguments.length == 2
|
|
10
|
-
|
|
11
|
-
from = Date.iso8601(from)
|
|
12
|
-
to = Date.iso8601(to)
|
|
13
|
-
raise Error, "end date must not be before start date" if to < from
|
|
14
|
-
|
|
15
|
-
client ||= Marlens::HarvestApiV2::Client.from_environment
|
|
16
|
-
assignments = client.active_personal_task_assignments.filter_map do |assignment|
|
|
17
|
-
project = assignment["project"]
|
|
18
|
-
task = assignment["task"]
|
|
19
|
-
next unless project&.fetch("id", nil) && project&.fetch("name", nil) && task&.fetch("id", nil) && task&.fetch("name", nil)
|
|
20
|
-
|
|
21
|
-
{ "project" => { "id" => project.fetch("id"), "name" => project.fetch("name") }, "task" => { "id" => task.fetch("id"), "name" => task.fetch("name") } }
|
|
22
|
-
end
|
|
23
|
-
entries = AggregateCLI.fetch_entries(client, from:, to:).filter_map do |entry|
|
|
24
|
-
project = entry["project"]
|
|
25
|
-
task = entry["task"]
|
|
26
|
-
next unless project&.fetch("id", nil) && project&.fetch("name", nil) && task&.fetch("id", nil) && task&.fetch("name", nil) && Float(entry.fetch("hours"))
|
|
27
|
-
|
|
28
|
-
{ "project" => { "id" => project.fetch("id"), "name" => project.fetch("name") }, "task" => { "id" => task.fetch("id"), "name" => task.fetch("name") }, "hours" => entry.fetch("hours") }
|
|
29
|
-
end
|
|
30
|
-
output.puts JSON.generate(assignments:, entries:)
|
|
31
|
-
0
|
|
32
|
-
rescue Error, Marlens::HarvestApiV2::Error, Date::Error => e
|
|
33
|
-
error.puts "Error: #{e.message}"
|
|
34
|
-
1
|
|
35
|
-
end
|
|
36
|
-
end
|
|
37
|
-
end
|
|
@@ -1,116 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module HarvestWorklog
|
|
4
|
-
class ReconcileCLI
|
|
5
|
-
def self.run(arguments, output: $stdout, error: $stderr, client: nil, log_path: default_log_path, today: Date.today)
|
|
6
|
-
options = {}
|
|
7
|
-
parser = option_parser(options)
|
|
8
|
-
dates = parser.parse(arguments)
|
|
9
|
-
validate!(dates, options)
|
|
10
|
-
|
|
11
|
-
date = TimesheetCLI.resolve_date(dates.first, today:)
|
|
12
|
-
client ||= Marlens::HarvestApiV2::Client.from_environment
|
|
13
|
-
user_id = TimesheetCLI.current_user_id(client)
|
|
14
|
-
harvest_entries = AggregateCLI.fetch_entries(client, from: date, to: date, user_id:).select do |entry|
|
|
15
|
-
AggregateCLI.matches?(entry, project: options[:harvest_project], task: options[:task])
|
|
16
|
-
end
|
|
17
|
-
print_reconciliation(
|
|
18
|
-
output,
|
|
19
|
-
date:,
|
|
20
|
-
local_project: options[:project],
|
|
21
|
-
intervals: local_intervals(log_path, date:, project: options[:project]),
|
|
22
|
-
harvest_entries:
|
|
23
|
-
)
|
|
24
|
-
0
|
|
25
|
-
rescue Error, Marlens::HarvestApiV2::Error, OptionParser::ParseError, Date::Error, Errno::ENOENT, JSON::ParserError => e
|
|
26
|
-
error.puts "Error: #{e.message}"
|
|
27
|
-
error.puts parser if parser
|
|
28
|
-
1
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
def self.option_parser(options)
|
|
32
|
-
OptionParser.new do |opts|
|
|
33
|
-
opts.banner = "Usage: harvest-worklog reconcile DATE --project PROJECT --harvest-project NAME [--task NAME]"
|
|
34
|
-
opts.on("--project NAME", "Filter local OMP Project Time project name") { |value| options[:project] = value }
|
|
35
|
-
opts.on("--harvest-project NAME", "Filter manual Harvest project name") { |value| options[:harvest_project] = value }
|
|
36
|
-
opts.on("--task NAME", "Filter manual Harvest task name") { |value| options[:task] = value }
|
|
37
|
-
opts.on("-h", "--help", "Show this help") do
|
|
38
|
-
puts opts
|
|
39
|
-
exit 0
|
|
40
|
-
end
|
|
41
|
-
end
|
|
42
|
-
end
|
|
43
|
-
|
|
44
|
-
def self.validate!(dates, options)
|
|
45
|
-
raise Error, "DATE is required" unless dates.length == 1
|
|
46
|
-
%i[project harvest_project].each do |name|
|
|
47
|
-
raise Error, "--#{name.to_s.tr('_', '-')} is required" unless options[name]&.strip&.length&.positive?
|
|
48
|
-
end
|
|
49
|
-
raise Error, "--task must not be blank" if options[:task]&.strip&.empty?
|
|
50
|
-
end
|
|
51
|
-
|
|
52
|
-
def self.default_log_path
|
|
53
|
-
File.join(Dir.home, ".omp", "project-time", "time-log.json")
|
|
54
|
-
end
|
|
55
|
-
|
|
56
|
-
def self.local_intervals(log_path, date:, project:)
|
|
57
|
-
state = JSON.parse(File.read(log_path))
|
|
58
|
-
entries = state.is_a?(Hash) ? state["entries"] : nil
|
|
59
|
-
raise Error, "OMP Project Time log is missing an entries array" unless entries.is_a?(Array)
|
|
60
|
-
|
|
61
|
-
day_start = Time.local(date.year, date.month, date.day).to_f * 1000
|
|
62
|
-
next_date = date + 1
|
|
63
|
-
day_end = Time.local(next_date.year, next_date.month, next_date.day).to_f * 1000
|
|
64
|
-
entries.filter_map do |entry|
|
|
65
|
-
next unless entry.is_a?(Hash) && entry["sourceKind"] == "human_active" && entry["project"] == project
|
|
66
|
-
|
|
67
|
-
start_at = entry["startAtMs"]
|
|
68
|
-
end_at = entry["endAtMs"]
|
|
69
|
-
raise Error, "OMP Project Time log contains an invalid session interval" unless start_at.is_a?(Numeric) && end_at.is_a?(Numeric) && start_at < end_at
|
|
70
|
-
|
|
71
|
-
start_at = [start_at, day_start].max
|
|
72
|
-
end_at = [end_at, day_end].min
|
|
73
|
-
[start_at, end_at] if start_at < end_at
|
|
74
|
-
end
|
|
75
|
-
end
|
|
76
|
-
|
|
77
|
-
def self.print_reconciliation(output, date:, local_project:, intervals:, harvest_entries:)
|
|
78
|
-
raw = intervals.sum { |start_at, end_at| end_at - start_at }
|
|
79
|
-
union = merged_duration(intervals)
|
|
80
|
-
harvest = AggregateCLI.total_hours(harvest_entries) * 3_600_000
|
|
81
|
-
|
|
82
|
-
output.puts "#{date.iso8601} reconciliation"
|
|
83
|
-
output.puts "Manual Harvest: #{duration(harvest)} (#{harvest_entries.length} #{AggregateCLI.entry_label(harvest_entries.length)})"
|
|
84
|
-
output.puts "Local OMP Project Time #{local_project}:"
|
|
85
|
-
output.puts " Raw intervals: #{duration(raw)}"
|
|
86
|
-
output.puts " Non-overlapping union: #{duration(union)}"
|
|
87
|
-
output.puts " Concurrent overlap: #{duration(raw - union)}"
|
|
88
|
-
output.puts "Harvest minus local raw: #{signed_duration(harvest - raw)}"
|
|
89
|
-
output.puts "Harvest minus local union: #{signed_duration(harvest - union)}"
|
|
90
|
-
output.puts "Verdict: Manual Harvest is the benchmark. This read-only comparison creates or changes no entries."
|
|
91
|
-
end
|
|
92
|
-
|
|
93
|
-
def self.merged_duration(intervals)
|
|
94
|
-
merged = intervals.sort_by(&:first).each_with_object([]) do |(start_at, end_at), result|
|
|
95
|
-
if result.empty? || start_at > result.last.last
|
|
96
|
-
result << [start_at, end_at]
|
|
97
|
-
else
|
|
98
|
-
result.last[1] = [result.last[1], end_at].max
|
|
99
|
-
end
|
|
100
|
-
end
|
|
101
|
-
merged.sum { |start_at, end_at| end_at - start_at }
|
|
102
|
-
end
|
|
103
|
-
|
|
104
|
-
def self.signed_duration(milliseconds)
|
|
105
|
-
seconds = (milliseconds / 1000.0).round
|
|
106
|
-
return duration(0) if seconds.zero?
|
|
107
|
-
|
|
108
|
-
"#{seconds.negative? ? '-' : '+'}#{duration(seconds.abs * 1000)}"
|
|
109
|
-
end
|
|
110
|
-
|
|
111
|
-
def self.duration(milliseconds)
|
|
112
|
-
seconds = (milliseconds / 1000.0).round
|
|
113
|
-
"%dh %dm %ds" % [seconds / 3600, seconds / 60 % 60, seconds % 60]
|
|
114
|
-
end
|
|
115
|
-
end
|
|
116
|
-
end
|
|
@@ -1,82 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module HarvestWorklog
|
|
4
|
-
class TimesheetCLI
|
|
5
|
-
def self.run(arguments, output: $stdout, error: $stderr, client: nil, today: Date.today)
|
|
6
|
-
options = {}
|
|
7
|
-
parser = option_parser(options)
|
|
8
|
-
dates = parser.parse(arguments)
|
|
9
|
-
validate!(dates, options)
|
|
10
|
-
|
|
11
|
-
spent_date = resolve_date(dates.first, today:)
|
|
12
|
-
client ||= Marlens::HarvestApiV2::Client.from_environment
|
|
13
|
-
user_id = current_user_id(client)
|
|
14
|
-
entries = AggregateCLI.fetch_entries(client, from: spent_date, to: spent_date, user_id:).select { |entry| AggregateCLI.matches?(entry, options) }
|
|
15
|
-
print_timesheet(output, entries, spent_date:, requested_project: options[:project])
|
|
16
|
-
0
|
|
17
|
-
rescue Error, Marlens::HarvestApiV2::Error, OptionParser::ParseError, Date::Error => e
|
|
18
|
-
error.puts "Error: #{e.message}"
|
|
19
|
-
error.puts parser if parser
|
|
20
|
-
1
|
|
21
|
-
end
|
|
22
|
-
|
|
23
|
-
def self.option_parser(options)
|
|
24
|
-
OptionParser.new do |opts|
|
|
25
|
-
opts.banner = "Usage: harvest-worklog timesheet DATE --project NAME [--task NAME]"
|
|
26
|
-
opts.on("--project NAME", "Filter by Harvest project name") { |value| options[:project] = value }
|
|
27
|
-
opts.on("--task NAME", "Filter by Harvest task name") { |value| options[:task] = value }
|
|
28
|
-
opts.on("-h", "--help", "Show this help") do
|
|
29
|
-
puts opts
|
|
30
|
-
exit 0
|
|
31
|
-
end
|
|
32
|
-
end
|
|
33
|
-
end
|
|
34
|
-
|
|
35
|
-
def self.resolve_date(value, today: Date.today)
|
|
36
|
-
case value.downcase
|
|
37
|
-
when "today" then today
|
|
38
|
-
when "yesterday" then today - 1
|
|
39
|
-
else Date.iso8601(value)
|
|
40
|
-
end
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
def self.current_user_id(client)
|
|
44
|
-
client.request(:get, "/v2/users/me", params: {}).fetch("id")
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
def self.validate!(dates, options)
|
|
48
|
-
raise Error, "DATE is required" unless dates.length == 1
|
|
49
|
-
raise Error, "--project is required" unless options[:project] && !options[:project].strip.empty?
|
|
50
|
-
raise Error, "--task must not be blank" if options[:task]&.strip&.empty?
|
|
51
|
-
end
|
|
52
|
-
|
|
53
|
-
def self.print_timesheet(output, entries, spent_date:, requested_project:)
|
|
54
|
-
project = entries.first&.dig("project", "name") || requested_project
|
|
55
|
-
output.puts "#{project} · #{spent_date.strftime("%a, %b %-d")} · #{HarvestWorklog.display_hours(AggregateCLI.total_hours(entries))}h"
|
|
56
|
-
if entries.empty?
|
|
57
|
-
output.puts
|
|
58
|
-
output.puts "No time entries."
|
|
59
|
-
return
|
|
60
|
-
end
|
|
61
|
-
|
|
62
|
-
entries.group_by { |entry| entry.dig("task", "name") }.sort_by { |task, _| task.downcase }.each do |task, task_entries|
|
|
63
|
-
output.puts
|
|
64
|
-
output.puts "#{task} · #{HarvestWorklog.display_hours(AggregateCLI.total_hours(task_entries))}h"
|
|
65
|
-
if task_entries.one?
|
|
66
|
-
print_notes(output, task_entries.first["notes"].to_s, indent: 2)
|
|
67
|
-
else
|
|
68
|
-
task_entries.each do |entry|
|
|
69
|
-
output.puts " #{HarvestWorklog.display_hours(Float(entry.fetch("hours")))}h"
|
|
70
|
-
print_notes(output, entry["notes"].to_s, indent: 4)
|
|
71
|
-
end
|
|
72
|
-
end
|
|
73
|
-
end
|
|
74
|
-
end
|
|
75
|
-
|
|
76
|
-
def self.print_notes(output, notes, indent:)
|
|
77
|
-
lines = notes.lines(chomp: true).reject(&:empty?)
|
|
78
|
-
lines = ["(no notes)"] if lines.empty?
|
|
79
|
-
lines.each { |line| output.puts "#{" " * indent}#{line}" }
|
|
80
|
-
end
|
|
81
|
-
end
|
|
82
|
-
end
|