omp-worklog 0.14.6 → 0.15.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0ee816df2ce095811beb7021f32892defbbf488840f02f0ff6e3019897f5298c
4
- data.tar.gz: 443a96a73fa7bd0f4d4c37089712e1fe7126f5bc6d2800be843bf727b816667b
3
+ metadata.gz: c9b72c4c74b6a576facff2242076d619179a1e2b441aef3617f98d4357018839
4
+ data.tar.gz: 5d0f548e70de14535544d1f0ab66d6c3060a4cacf4393fe11dfead4b5941b68c
5
5
  SHA512:
6
- metadata.gz: 2f54d4a599a17e35d500617c0aa7f44fa591f9167861343a414b3d650266b0c0e843dcdc4f84009f81c9939260b26bef867e00d43fd743418518895c7d7fd83c
7
- data.tar.gz: 4a89084e3d29bc60fac8eaf06da3c409ec0b165fefc316f5d65777864f61cb2c0fbe55f33829589679d5a7608b74c087a27f89c56db39c725c0782569d4d1bc2
6
+ metadata.gz: 6c9e6f039e18d6a5ef5e93519d4241a7f285fa82c9958cad5d5f2d966b1775f0aa347e14cee382802d3023a59ed424b15d1634c05863800194f022d94cb21fca
7
+ data.tar.gz: e351d67770dc43500ab63ca325427fdd4aa22559c7690bea961abc6cc6dab194e342c601c72fc672b6ad5c7b5a04d4fbbf545e784f804ff497168ce28fa5c76f
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # OMP Worklog
2
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.
3
+ OMP Worklog is a Project Time-driven Ruby CLI and OMP plugin for reviewed personal work logs. It reports human/AI observations separately, prepares independent concurrent-project candidates, and files a reviewed daily batch to Harvest after explicit approval. Time-off and ordinary-work commands remain available.
4
4
 
5
5
  ## Documentation
6
6
 
@@ -77,7 +77,21 @@ User stories:
77
77
 
78
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
79
 
80
- Type `/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.
80
+ Type `/worklog ` to discover timesheet, stats, plan, and submit. Date aliases are completed contextually. After `--project`, timesheet completion lists human-active projects; stats and plan also include agent-only projects. Completion preserves exact local project names.
81
+
82
+ ### Daily Human and AI Reports and Reviewed Batches
83
+
84
+ ```text
85
+ /worklog stats 2026-10-05 2026-10-18
86
+ /worklog stats today --until 12:00
87
+ /worklog plan yesterday
88
+ /worklog submit "PATH-PRINTED-BY-PLAN" --dry-run
89
+ /worklog submit "PATH-PRINTED-BY-PLAN"
90
+ ```
91
+
92
+ Different projects retain independent candidate durations; overlapping human/agent evidence within one project is counted once. Only same-session gaps within the five-minute response grace may be inferred, and historical progress/blocker state still needs review. Plans are private, unapproved JSON files with required factual notes and reviewer-adjusted durations. The interactive submit asks for one explicit approval, preflights every record, and then files the batch with duplicate and uncertain-outcome protection.
93
+
94
+ See [reviewed concurrent worklogs](/docs/billing.md) for the policy, review fields, CLI/tool interfaces, private backups, and recovery. Headless callers can use `omp-worklog work-entry-batch [--dry-run]` with a reviewed JSON document on stdin; actual writes require `approved: true`.
81
95
 
82
96
  ## OMP settings
83
97
 
@@ -88,6 +102,7 @@ Worklog resolves persisted user settings and project overrides through the suppo
88
102
  - `command`: direct path to the `omp-worklog` executable.
89
103
  - `projectTimeLogPath`: optional path to Project Time v1 evidence; defaults to `~/.omp/project-time/time-log.sqlite`. A legacy versioned JSON snapshot is also supported.
90
104
  - `projectTimeMappings`: optional JSON mapping of exact local projects to Harvest project/task names; defaults to `{}`.
105
+ - `responseGraceMinutes`: same-session response-gap inference for review candidates; defaults to `5`, with `0` disabling gap inference. This does not certify progress or authorize billing.
91
106
 
92
107
  The host stores user plugin settings in `~/.omp/plugins/omp-plugins.lock.json` and project overrides in `.omp/plugin-overrides.json`. Back up private configuration outside Git before uninstalling/reinstalling a plugin. Worklog deliberately does not parse these files itself or bypass project precedence.
93
108
 
@@ -113,14 +128,18 @@ For independent comparison with manually recorded Harvest entries, follow the [p
113
128
 
114
129
  `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.
115
130
 
116
-
117
-
118
131
  ## Migration from Harvest Worklog
119
132
 
120
133
  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.
121
134
 
122
135
  Version `0.14.3` renames the OMP slash command from `/omp-worklog` to `/worklog`; the `omp-worklog` package identity and CLI executable are unchanged.
123
136
 
137
+ ## Changes in 0.15.0
138
+
139
+ - Add daily/project human-active and AI elapsed reports with a final-day cutoff.
140
+ - Prepare private concurrent-project review batches with within-project deduplication and five-minute same-session response grace.
141
+ - File explicitly approved batches with user-scoped preflight, stable identities, private receipts, and safe partial/uncertain-result handling.
142
+
124
143
  ## Changes in 0.14.6
125
144
 
126
145
  - Activate persisted settings through the supported host CLI before registering commands, completion, and tools.
@@ -143,7 +162,7 @@ Version `0.14.3` renames the OMP slash command from `/omp-worklog` to `/worklog`
143
162
  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:
144
163
 
145
164
  ```bash
146
- ruby test_omp_worklog.rb
165
+ npm run test:ruby
147
166
  npm run test:omp
148
167
  bin/omp-worklog --help
149
168
  VERSION="$(ruby -Ilib -romp_worklog/version -e 'puts OmpWorklog::VERSION')"
@@ -0,0 +1,755 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "fileutils"
5
+ require "json"
6
+ require "optparse"
7
+ require "tempfile"
8
+
9
+ module OmpWorklog
10
+ class BatchCLI
11
+ FORMAT = "omp-worklog/review-batch"
12
+ RESULT_FORMAT = "omp-worklog/batch-result"
13
+ ID_PREFIX = "omp-worklog-v1:"
14
+ RECEIPT_FORMAT = "omp-worklog/submission-receipts"
15
+ PER_PAGE = 200
16
+
17
+ TOP_LEVEL_KEYS = %w[format version approved from to policy entries].freeze
18
+ POLICY_KEYS = %w[mode responseGraceMinutes].freeze
19
+ ENTRY_KEYS = %w[
20
+ id spentDate localProject project task candidateMilliseconds milliseconds
21
+ humanActiveMilliseconds agentTurnMilliseconds bridgedGapMilliseconds
22
+ sourceEntryIds notes reviewed reviewReasons
23
+ ].freeze
24
+
25
+ class InputError < StandardError
26
+ attr_reader :code
27
+
28
+ def initialize(code)
29
+ @code = code
30
+ super(code)
31
+ end
32
+ end
33
+
34
+ class ProviderFailure < StandardError
35
+ attr_reader :code
36
+
37
+ def initialize(code)
38
+ @code = code
39
+ super(code)
40
+ end
41
+ end
42
+
43
+ def self.run(arguments, input: $stdin, output: $stdout, error: $stderr, client: nil, environment: ENV)
44
+ new(arguments, input:, output:, error:, client:, environment:).run
45
+ end
46
+
47
+ def initialize(arguments, input:, output:, error:, client:, environment:)
48
+ @arguments = arguments.dup
49
+ @input = input
50
+ @output = output
51
+ @error = error
52
+ @client = client
53
+ @environment = environment
54
+ @dry_run = false
55
+ @state_dir = nil
56
+ end
57
+
58
+ def run
59
+ parse_options!
60
+ batch = parse_and_validate_batch!
61
+ require_approval!(batch)
62
+ @account_id = account_id_from_environment
63
+ client = @client || Marlens::HarvestApiV2::Client.from_environment(environment: @environment)
64
+ user_id = current_user_id(client)
65
+
66
+ store = ReceiptStore.new(receipt_directory, @account_id, user_id)
67
+ result = if @dry_run
68
+ preflight_result(batch, client, user_id, [store, store.read_only])
69
+ else
70
+ store.with_lock do |receipts|
71
+ preflight_result(batch, client, user_id, [store, receipts])
72
+ end
73
+ end
74
+
75
+ emit_result(result)
76
+ successful_result?(result) ? 0 : 1
77
+ rescue InputError => error
78
+ emit_failure(@dry_run ? "preflight" : "submit", error.code)
79
+ 1
80
+ rescue ProviderFailure => error
81
+ emit_failure(@dry_run ? "preflight" : "submit", error.code)
82
+ 1
83
+ rescue OptionParser::ParseError
84
+ emit_failure(@dry_run ? "preflight" : "submit", "invalid_options")
85
+ 1
86
+ rescue StandardError
87
+ emit_failure(@dry_run ? "preflight" : "submit", "submission_failed")
88
+ 1
89
+ end
90
+
91
+ private
92
+
93
+ def parse_options!
94
+ parser = OptionParser.new do |options|
95
+ options.on("--dry-run", "Validate and preflight without receipt changes or writes") { @dry_run = true }
96
+ options.on("--state-dir DIR", "Use a private receipt directory") { |directory| @state_dir = directory }
97
+ end
98
+ parser.parse!(@arguments)
99
+ raise InputError, "invalid_options" unless @arguments.empty?
100
+ end
101
+
102
+ def parse_and_validate_batch!
103
+ raw = JSON.parse(@input.read)
104
+ validate_batch!(raw)
105
+ rescue JSON::ParserError
106
+ raise InputError, "invalid_json"
107
+ end
108
+
109
+ def validate_batch!(batch)
110
+ require_hash_with_keys!(batch, TOP_LEVEL_KEYS, "invalid_batch_schema")
111
+ raise InputError, "invalid_batch_format" unless batch["format"] == FORMAT && batch["version"].is_a?(Integer) && batch["version"] == 1
112
+ raise InputError, "invalid_approval" unless [true, false].include?(batch["approved"])
113
+
114
+ from = valid_date!(batch["from"], "invalid_date_range")
115
+ to = valid_date!(batch["to"], "invalid_date_range")
116
+ raise InputError, "invalid_date_range" if to < from
117
+
118
+ policy = batch["policy"]
119
+ require_hash_with_keys!(policy, POLICY_KEYS, "invalid_batch_schema")
120
+ raise InputError, "invalid_policy" unless policy["mode"] == "concurrent-projects"
121
+ grace = policy["responseGraceMinutes"]
122
+ raise InputError, "invalid_policy" unless grace.is_a?(Integer) && grace >= 0
123
+
124
+ entries = batch["entries"]
125
+ raise InputError, "invalid_entries" unless entries.is_a?(Array) && !entries.empty?
126
+
127
+ seen_ids = {}
128
+ validated_entries = entries.map do |entry|
129
+ validate_entry!(entry, from, to, seen_ids)
130
+ end
131
+
132
+ {
133
+ approved: batch["approved"],
134
+ entries: validated_entries
135
+ }
136
+ end
137
+
138
+ def validate_entry!(entry, from, to, seen_ids)
139
+ require_hash_with_keys!(entry, ENTRY_KEYS, "invalid_entry_schema")
140
+ spent_date = valid_date!(entry["spentDate"], "invalid_entry_date")
141
+ raise InputError, "entry_date_out_of_range" unless spent_date.between?(from, to)
142
+
143
+ local_project = nonblank_string!(entry["localProject"], "invalid_entry_project")
144
+ id = nonblank_string!(entry["id"], "invalid_entry_id")
145
+ expected_id = identity_for(spent_date.iso8601, local_project)
146
+ raise InputError, "invalid_entry_identity" unless id == expected_id
147
+ raise InputError, "duplicate_identity" if seen_ids[id]
148
+ seen_ids[id] = true
149
+
150
+ candidate = positive_integer!(entry["candidateMilliseconds"], "invalid_duration")
151
+ milliseconds = positive_integer!(entry["milliseconds"], "invalid_duration")
152
+ raise InputError, "duration_exceeds_candidate" if milliseconds > candidate
153
+
154
+ nonnegative_integer!(entry["humanActiveMilliseconds"], "invalid_entry_schema")
155
+ nonnegative_integer!(entry["agentTurnMilliseconds"], "invalid_entry_schema")
156
+ nonnegative_integer!(entry["bridgedGapMilliseconds"], "invalid_entry_schema")
157
+ validate_string_array!(entry["sourceEntryIds"], "invalid_entry_schema", allow_empty: true)
158
+ validate_string_array!(entry["reviewReasons"], "invalid_entry_schema", allow_empty: true)
159
+ raise InputError, "entry_not_reviewed" unless entry["reviewed"] == true
160
+
161
+ project = nonblank_string!(entry["project"], "destination_required")
162
+ task = nonblank_string!(entry["task"], "destination_required")
163
+ notes = nonblank_string!(entry["notes"], "reviewed_notes_required")
164
+
165
+ {
166
+ id:,
167
+ spent_date: spent_date.iso8601,
168
+ local_project:,
169
+ project:,
170
+ task:,
171
+ milliseconds:,
172
+ notes:
173
+ }
174
+ end
175
+
176
+ def require_approval!(batch)
177
+ raise InputError, "batch_not_approved" unless @dry_run || batch[:approved]
178
+ end
179
+
180
+ def require_hash_with_keys!(value, keys, code)
181
+ raise InputError, code unless value.is_a?(Hash) && value.keys.sort == keys.sort
182
+ end
183
+
184
+ def nonblank_string!(value, code)
185
+ raise InputError, code unless value.is_a?(String) && !value.strip.empty?
186
+
187
+ value
188
+ end
189
+
190
+ def positive_integer!(value, code)
191
+ raise InputError, code unless value.is_a?(Integer) && value.positive?
192
+
193
+ value
194
+ end
195
+
196
+ def nonnegative_integer!(value, code)
197
+ raise InputError, code unless value.is_a?(Integer) && value >= 0
198
+
199
+ value
200
+ end
201
+
202
+ def validate_string_array!(value, code, allow_empty:)
203
+ raise InputError, code unless value.is_a?(Array) && (allow_empty || !value.empty?)
204
+ raise InputError, code unless value.all? { |item| item.is_a?(String) && !item.strip.empty? }
205
+ end
206
+
207
+ def valid_date!(value, code)
208
+ raise InputError, code unless value.is_a?(String)
209
+
210
+ date = Date.iso8601(value)
211
+ raise InputError, code unless date.iso8601 == value
212
+
213
+ date
214
+ rescue Date::Error
215
+ raise InputError, code
216
+ end
217
+
218
+ def identity_for(spent_date, local_project)
219
+ digest = Digest::SHA256.hexdigest(JSON.generate(["omp-worklog/v1", spent_date, local_project]))
220
+ "#{ID_PREFIX}#{digest}"
221
+ end
222
+
223
+ def account_id_from_environment
224
+ account_id = @environment["HARVEST_ACCOUNT_ID"]
225
+ raise InputError, "account_scope_required" unless account_id.is_a?(String) && !account_id.strip.empty?
226
+
227
+ account_id
228
+ end
229
+
230
+ def current_user_id(client)
231
+ user = request_object(client, :get, "/v2/users/me")
232
+ id = user["id"]
233
+ raise ProviderFailure, "invalid_current_user" unless valid_provider_id?(id)
234
+
235
+ id
236
+ end
237
+
238
+ def preflight_result(batch, client, user_id, receipt_context)
239
+ company = request_object(client, :get, "/v2/company")
240
+ timestamp_mode = company["wants_timestamp_timers"]
241
+ raise ProviderFailure, "invalid_company_response" unless [true, false].include?(timestamp_mode)
242
+ raise ProviderFailure, "timestamp_timer_mode_unsupported" if timestamp_mode
243
+
244
+ resolved_entries, resolution_results = resolve_entries(client, batch[:entries])
245
+ unless resolved_entries.length == batch[:entries].length
246
+ resolution_results.each do |result|
247
+ result["status"] = "not_submitted" if result["status"] == "ready"
248
+ result["reason"] ||= "batch_preflight_failed" if result["status"] == "not_submitted"
249
+ end
250
+ return build_result(@dry_run ? "preflight" : "submit", "blocked", resolution_results)
251
+ end
252
+
253
+ if duplicate_destination?(resolved_entries)
254
+ results = resolved_entries.map do |entry|
255
+ result_with_status(entry_result(entry, "conflict"), "conflict", "duplicate_destination")
256
+ end
257
+ return build_result(@dry_run ? "preflight" : "submit", "blocked", results)
258
+ end
259
+
260
+ remote_records = inspect_remote_entries(client, resolved_entries, user_id)
261
+ results = resolved_entries.map do |entry|
262
+ preflight_entry_result(entry, remote_records, receipt_context)
263
+ end
264
+
265
+ unless results.all? { |result| %w[ready already_filed].include?(result["status"]) }
266
+ results.each do |result|
267
+ result["status"] = "not_submitted" if result["status"] == "ready"
268
+ result["reason"] ||= "batch_preflight_failed" if result["status"] == "not_submitted"
269
+ end
270
+ status = results.any? { |result| result["status"] == "uncertain" } ? "uncertain" : "blocked"
271
+ return build_result(@dry_run ? "preflight" : "submit", status, results)
272
+ end
273
+
274
+ return build_result("preflight", "ready", results) if @dry_run
275
+
276
+ store, receipts = receipt_context
277
+ file_ready_entries(resolved_entries, results, store, receipts, client, user_id)
278
+ end
279
+
280
+ def request_object(client, method, path, params: {}, body: nil)
281
+ response = client.request(method, path, params:, body:)
282
+ raise ProviderFailure, "invalid_provider_response" unless response.is_a?(Hash)
283
+
284
+ response
285
+ rescue ProviderFailure
286
+ raise
287
+ rescue StandardError
288
+ raise ProviderFailure, "provider_request_failed"
289
+ end
290
+
291
+ def valid_provider_id?(value)
292
+ (value.is_a?(Integer) && value.positive?) || (value.is_a?(String) && value.match?(/\A[1-9]\d*\z/))
293
+ end
294
+
295
+ def resolve_entries(client, entries)
296
+ assignments = personal_assignments(client)
297
+ resolved_entries = []
298
+ results = entries.map do |entry|
299
+ matches = assignments.select do |assignment|
300
+ assignment[:project_name].casecmp?(entry[:project]) && assignment[:task_name].casecmp?(entry[:task])
301
+ end
302
+ if matches.empty?
303
+ result_with_status(entry_result(entry, "blocked"), "blocked", "destination_unmapped")
304
+ elsif matches.length > 1
305
+ result_with_status(entry_result(entry, "blocked"), "blocked", "destination_ambiguous")
306
+ else
307
+ assignment = matches.first
308
+ resolved_entries << entry.merge(project_id: assignment[:project_id], task_id: assignment[:task_id])
309
+ entry_result(entry, "ready")
310
+ end
311
+ end
312
+
313
+ [resolved_entries, results]
314
+ end
315
+
316
+ def personal_assignments(client)
317
+ assignments = paged_records(client, "/v2/users/me/project_assignments", "project_assignments", is_active: true)
318
+ assignments.filter_map do |project_assignment|
319
+ unless [true, false].include?(project_assignment["is_active"])
320
+ raise ProviderFailure, "invalid_assignment_response"
321
+ end
322
+ next unless project_assignment["is_active"]
323
+
324
+ project = project_assignment["project"]
325
+ raise ProviderFailure, "invalid_assignment_response" unless project.is_a?(Hash)
326
+ if project.key?("is_active") && ![true, false].include?(project["is_active"])
327
+ raise ProviderFailure, "invalid_assignment_response"
328
+ end
329
+ next if project["is_active"] == false
330
+ unless valid_provider_id?(project["id"]) && project["name"].is_a?(String) && !project["name"].strip.empty?
331
+ raise ProviderFailure, "invalid_assignment_response"
332
+ end
333
+
334
+ task_assignments = project_assignment["task_assignments"]
335
+ raise ProviderFailure, "invalid_assignment_response" unless task_assignments.is_a?(Array)
336
+
337
+ task_assignments.filter_map do |task_assignment|
338
+ raise ProviderFailure, "invalid_assignment_response" unless task_assignment.is_a?(Hash)
339
+ unless [true, false].include?(task_assignment["is_active"])
340
+ raise ProviderFailure, "invalid_assignment_response"
341
+ end
342
+ next unless task_assignment["is_active"]
343
+
344
+ task = task_assignment["task"]
345
+ raise ProviderFailure, "invalid_assignment_response" unless task.is_a?(Hash)
346
+ unless valid_provider_id?(task["id"]) && task["name"].is_a?(String) && !task["name"].strip.empty?
347
+ raise ProviderFailure, "invalid_assignment_response"
348
+ end
349
+
350
+ {
351
+ project_id: project["id"],
352
+ project_name: project["name"],
353
+ task_id: task["id"],
354
+ task_name: task["name"]
355
+ }
356
+ end
357
+ end.flatten
358
+ end
359
+
360
+ def paged_records(client, path, collection_key, **params)
361
+ records = []
362
+ page = 1
363
+ seen_pages = {}
364
+ seen_records = {}
365
+
366
+ loop do
367
+ raise ProviderFailure, "invalid_pagination" if seen_pages[page]
368
+ seen_pages[page] = true
369
+ response = request_object(client, :get, path, params: params.merge(page:, per_page: PER_PAGE))
370
+ page_records = response[collection_key]
371
+ raise ProviderFailure, "invalid_provider_response" unless page_records.is_a?(Array) && page_records.all? { |record| record.is_a?(Hash) }
372
+
373
+ page_records.each do |record|
374
+ signature = Digest::SHA256.hexdigest(JSON.generate(record))
375
+ raise ProviderFailure, "invalid_pagination" if seen_records[signature]
376
+
377
+ seen_records[signature] = true
378
+ end
379
+ records.concat(page_records)
380
+ next_page = response["next_page"]
381
+ # If the provider omits next_page, a full page still requires a conservative follow-up request.
382
+ if next_page.nil?
383
+ break if page_records.length < PER_PAGE
384
+
385
+ page += 1
386
+ next
387
+ end
388
+
389
+ next_page = parse_page_number(next_page)
390
+ raise ProviderFailure, "invalid_pagination" if seen_pages[next_page]
391
+
392
+ page = next_page
393
+ end
394
+
395
+ records
396
+ end
397
+
398
+ def parse_page_number(value)
399
+ page = if value.is_a?(Integer)
400
+ value
401
+ elsif value.is_a?(String) && value.match?(/\A[1-9]\d*\z/)
402
+ value.to_i
403
+ end
404
+ raise ProviderFailure, "invalid_pagination" unless page && page.positive?
405
+
406
+ page
407
+ end
408
+
409
+ def duplicate_destination?(entries)
410
+ destinations = entries.map { |entry| [entry[:spent_date], id_string(entry[:project_id]), id_string(entry[:task_id])] }
411
+ destinations.uniq.length != destinations.length
412
+ end
413
+
414
+ def inspect_remote_entries(client, entries, user_id)
415
+ by_reference = {}
416
+ entries.each do |entry|
417
+ matches = paged_records(
418
+ client,
419
+ "/v2/time_entries",
420
+ "time_entries",
421
+ user_id:, external_reference_id: entry[:id], from: entry[:spent_date], to: entry[:spent_date]
422
+ )
423
+ matches = current_user_records(matches, user_id)
424
+ validate_time_entry_records!(matches)
425
+ matches.each do |record|
426
+ raise ProviderFailure, "invalid_provider_response" unless record.dig("external_reference", "id") == entry[:id]
427
+ end
428
+ by_reference[entry[:id]] = matches
429
+ end
430
+
431
+ by_date = {}
432
+ entries.map { |entry| entry[:spent_date] }.uniq.each do |spent_date|
433
+ records = paged_records(
434
+ client,
435
+ "/v2/time_entries",
436
+ "time_entries",
437
+ user_id:, from: spent_date, to: spent_date
438
+ )
439
+ records = current_user_records(records, user_id)
440
+ validate_time_entry_records!(records)
441
+ by_date[spent_date] = records
442
+ end
443
+
444
+ { by_reference:, by_date: }
445
+ end
446
+
447
+ def current_user_records(records, user_id)
448
+ records.filter_map do |record|
449
+ record_user_id = record["user"].is_a?(Hash) ? record["user"]["id"] : nil
450
+ unless valid_provider_id?(record_user_id)
451
+ raise ProviderFailure, "invalid_provider_response"
452
+ end
453
+ if id_string(record_user_id) != id_string(user_id)
454
+ next
455
+ end
456
+
457
+ record
458
+ end
459
+ end
460
+
461
+ def validate_time_entry_records!(records)
462
+ records.each do |record|
463
+ unless valid_provider_id?(record["id"]) && record["project"].is_a?(Hash) &&
464
+ valid_provider_id?(record["project"]["id"]) && record["task"].is_a?(Hash) &&
465
+ valid_provider_id?(record["task"]["id"]) && record["spent_date"].is_a?(String)
466
+ raise ProviderFailure, "invalid_provider_response"
467
+ end
468
+
469
+ begin
470
+ date = Date.iso8601(record["spent_date"])
471
+ rescue Date::Error
472
+ raise ProviderFailure, "invalid_provider_response"
473
+ end
474
+ raise ProviderFailure, "invalid_provider_response" unless date.iso8601 == record["spent_date"]
475
+ raise ProviderFailure, "invalid_provider_response" unless record["hours"].nil? || record["hours"].is_a?(Numeric)
476
+ raise ProviderFailure, "invalid_provider_response" unless record["notes"].nil? || record["notes"].is_a?(String)
477
+
478
+ reference = record["external_reference"]
479
+ if !reference.nil? && (!reference.is_a?(Hash) || !reference["id"].is_a?(String) || reference["id"].empty?)
480
+ raise ProviderFailure, "invalid_provider_response"
481
+ end
482
+ end
483
+ end
484
+
485
+ def preflight_entry_result(entry, remote_records, receipt_context)
486
+ result = entry_result(entry, "ready")
487
+ matching_records = remote_records[:by_reference].fetch(entry[:id])
488
+ if matching_records.any?
489
+ if matching_records.length != 1
490
+ return result_with_status(result, "conflict", "duplicate_external_reference")
491
+ end
492
+
493
+ record = matching_records.first
494
+ return result_with_status(result, "already_filed", nil, record["id"]) if exact_remote_match?(entry, record)
495
+
496
+ return result_with_status(result, "conflict", "external_reference_content_conflict")
497
+ end
498
+
499
+ destination_records = remote_records[:by_date].fetch(entry[:spent_date]).select do |record|
500
+ id_string(record.dig("project", "id")) == id_string(entry[:project_id]) &&
501
+ id_string(record.dig("task", "id")) == id_string(entry[:task_id])
502
+ end
503
+ if destination_records.any?
504
+ reason = destination_records.any? { |record| record["is_locked"] == true || record["is_running"] == true } ?
505
+ "locked_or_running_entry" : "existing_entry"
506
+ return result_with_status(result, "blocked", reason)
507
+ end
508
+
509
+ receipt = if receipt_context
510
+ receipt_context.last[entry[:id]]
511
+ end
512
+ if receipt && %w[pending uncertain submitted].include?(receipt["status"])
513
+ unless receipt["fingerprint"] == fingerprint(entry)
514
+ return result_with_status(result, "conflict", "receipt_content_conflict")
515
+ end
516
+
517
+ return result_with_status(result, "uncertain", "remote_entry_not_found")
518
+ end
519
+
520
+ result
521
+ end
522
+
523
+ def exact_remote_match?(entry, record)
524
+ valid_provider_id?(record["id"]) &&
525
+ record["spent_date"] == entry[:spent_date] &&
526
+ id_string(record.dig("project", "id")) == id_string(entry[:project_id]) &&
527
+ id_string(record.dig("task", "id")) == id_string(entry[:task_id]) &&
528
+ numeric_hours?(record["hours"], entry[:milliseconds]) &&
529
+ record["notes"] == entry[:notes] && record["is_running"] == false
530
+ end
531
+
532
+ def numeric_hours?(hours, milliseconds)
533
+ hours.is_a?(Numeric) && hours == milliseconds / 3_600_000.0
534
+ end
535
+
536
+ def id_string(value)
537
+ value.nil? ? "" : value.to_s
538
+ end
539
+
540
+ def fingerprint(entry)
541
+ canonical = [
542
+ entry[:id], entry[:spent_date], entry[:project_id], entry[:task_id],
543
+ entry[:milliseconds], entry[:notes]
544
+ ]
545
+ Digest::SHA256.hexdigest(JSON.generate(canonical))
546
+ end
547
+
548
+ def entry_result(entry, status)
549
+ {
550
+ "id" => entry[:id],
551
+ "spentDate" => entry[:spent_date],
552
+ "localProject" => entry[:local_project],
553
+ "status" => status
554
+ }
555
+ end
556
+
557
+ def result_with_status(result, status, reason, harvest_entry_id = nil)
558
+ result["status"] = status
559
+ result["reason"] = reason if reason
560
+ result["harvestEntryId"] = harvest_entry_id if harvest_entry_id
561
+ result
562
+ end
563
+
564
+ def file_ready_entries(entries, results, store, receipts, client, user_id)
565
+ entries.each_with_index do |entry, index|
566
+ if results[index]["status"] == "already_filed"
567
+ receipts[entry[:id]] = receipt_for(entry, "submitted", results[index]["harvestEntryId"])
568
+ end
569
+ end
570
+ store.save!(receipts) if results.any? { |result| result["status"] == "already_filed" }
571
+
572
+ entries.each_with_index do |entry, index|
573
+ next unless results[index]["status"] == "ready"
574
+
575
+ receipts[entry[:id]] = receipt_for(entry, "pending")
576
+ store.save!(receipts)
577
+ begin
578
+ response = client.request(
579
+ :post,
580
+ "/v2/time_entries",
581
+ body: {
582
+ user_id:,
583
+ project_id: entry[:project_id],
584
+ task_id: entry[:task_id],
585
+ spent_date: entry[:spent_date],
586
+ hours: entry[:milliseconds] / 3_600_000.0,
587
+ notes: entry[:notes],
588
+ external_reference: { id: entry[:id] }
589
+ }
590
+ )
591
+ harvest_entry_id = response.is_a?(Hash) ? response["id"] : nil
592
+ unless valid_provider_id?(harvest_entry_id)
593
+ receipts[entry[:id]] = receipt_for(entry, "uncertain")
594
+ store.save!(receipts)
595
+ result_with_status(results[index], "uncertain", "invalid_create_response")
596
+ mark_not_submitted(results, index + 1)
597
+ return build_result("submit", "uncertain", results)
598
+ end
599
+
600
+ receipts[entry[:id]] = receipt_for(entry, "submitted", harvest_entry_id)
601
+ store.save!(receipts)
602
+ result_with_status(results[index], "created", nil, harvest_entry_id)
603
+ rescue Marlens::HarvestApiV2::Error => error
604
+ if %w[400 401 403 404 422 429].include?(error.status.to_s)
605
+ receipts[entry[:id]] = receipt_for(entry, "rejected")
606
+ store.save!(receipts)
607
+ result_with_status(results[index], "blocked", "provider_rejected")
608
+ else
609
+ receipts[entry[:id]] = receipt_for(entry, "uncertain")
610
+ store.save!(receipts)
611
+ result_with_status(results[index], "uncertain", "create_outcome_unknown")
612
+ end
613
+ mark_not_submitted(results, index + 1)
614
+ return submission_result(results)
615
+ rescue StandardError
616
+ receipts[entry[:id]] = receipt_for(entry, "uncertain")
617
+ begin
618
+ store.save!(receipts)
619
+ rescue StandardError
620
+ # The pending receipt was durable before the request.
621
+ end
622
+ result_with_status(results[index], "uncertain", "create_outcome_unknown")
623
+ mark_not_submitted(results, index + 1)
624
+ return build_result("submit", "uncertain", results)
625
+ end
626
+ end
627
+
628
+ build_result("submit", "complete", results)
629
+ end
630
+
631
+ def receipt_for(entry, status, harvest_entry_id = nil)
632
+ receipt = { "fingerprint" => fingerprint(entry), "status" => status }
633
+ receipt["harvestEntryId"] = harvest_entry_id if harvest_entry_id
634
+ receipt
635
+ end
636
+
637
+ def mark_not_submitted(results, start_index)
638
+ results.each_with_index do |result, index|
639
+ next if index < start_index || result["status"] != "ready"
640
+
641
+ result["status"] = "not_submitted"
642
+ result["reason"] = "earlier_create_failed"
643
+ end
644
+ end
645
+
646
+ def submission_result(results)
647
+ return build_result("submit", "uncertain", results) if results.any? { |result| result["status"] == "uncertain" }
648
+
649
+ succeeded = results.any? { |result| %w[created already_filed].include?(result["status"]) }
650
+ build_result("submit", succeeded ? "partial" : "blocked", results)
651
+ end
652
+
653
+ def build_result(mode, status, results)
654
+ {
655
+ "format" => RESULT_FORMAT,
656
+ "version" => 1,
657
+ "mode" => mode,
658
+ "status" => status,
659
+ "results" => results
660
+ }
661
+ end
662
+
663
+ def successful_result?(result)
664
+ %w[ready complete].include?(result["status"])
665
+ end
666
+
667
+ def emit_result(result)
668
+ @output.puts JSON.generate(result)
669
+ end
670
+
671
+ def emit_failure(mode, reason)
672
+ @error.puts "Error: #{reason}"
673
+ emit_result(build_result(mode, "blocked", []).merge("reason" => reason))
674
+ end
675
+
676
+ def receipt_directory
677
+ @state_dir || File.join(Dir.home, ".omp", "worklog", "submissions")
678
+ end
679
+
680
+ class ReceiptStore
681
+ def initialize(directory, account_id, user_id)
682
+ @directory = directory
683
+ @scope = Digest::SHA256.hexdigest(JSON.generate([account_id, user_id]))
684
+ @receipt_path = File.join(directory, "#{@scope}.json")
685
+ @lock_path = File.join(directory, "#{@scope}.lock")
686
+ end
687
+
688
+ def read_only
689
+ return {} unless File.exist?(@directory)
690
+ raise ProviderFailure, "unsafe_receipt_directory" if File.lstat(@directory).symlink?
691
+
692
+ load_receipts
693
+ end
694
+
695
+ def with_lock
696
+ FileUtils.mkdir_p(@directory, mode: 0o700)
697
+ raise ProviderFailure, "unsafe_receipt_directory" if File.lstat(@directory).symlink?
698
+
699
+ File.chmod(0o700, @directory)
700
+ File.open(@lock_path, File::RDWR | File::CREAT | File::NOFOLLOW, 0o600) do |lock|
701
+ begin
702
+ raise ProviderFailure, "unsafe_receipt_lock" unless lock.stat.file?
703
+
704
+ File.chmod(0o600, @lock_path)
705
+ File.chmod(0o600, @receipt_path) if File.exist?(@receipt_path) && !File.lstat(@receipt_path).symlink?
706
+ lock.flock(File::LOCK_EX)
707
+ yield load_receipts
708
+ ensure
709
+ lock.flock(File::LOCK_UN)
710
+ end
711
+ end
712
+ rescue ProviderFailure
713
+ raise
714
+ rescue StandardError
715
+ raise ProviderFailure, "receipt_storage_failed"
716
+ end
717
+
718
+ def save!(receipts)
719
+ Tempfile.create([".receipt-", ".tmp"], @directory, mode: 0o600) do |temporary|
720
+ temporary.chmod(0o600)
721
+ temporary.write(JSON.generate({ "format" => RECEIPT_FORMAT, "version" => 1, "entries" => receipts }))
722
+ temporary.flush
723
+ temporary.fsync
724
+ File.rename(temporary.path, @receipt_path)
725
+ File.open(@directory, File::RDONLY) { |directory| directory.fsync }
726
+ end
727
+ rescue StandardError
728
+ raise ProviderFailure, "receipt_storage_failed"
729
+ end
730
+
731
+ private
732
+
733
+ def load_receipts
734
+ return {} unless File.exist?(@receipt_path)
735
+
736
+ raise ProviderFailure, "unsafe_receipt_file" if File.lstat(@receipt_path).symlink?
737
+ document = File.open(@receipt_path, File::RDONLY | File::NOFOLLOW, &:read)
738
+ data = JSON.parse(document)
739
+ unless data.is_a?(Hash) && data["format"] == RECEIPT_FORMAT && data["version"] == 1 && data["entries"].is_a?(Hash)
740
+ raise ProviderFailure, "invalid_receipt_state"
741
+ end
742
+
743
+ data["entries"].each_value do |receipt|
744
+ unless receipt.is_a?(Hash) && %w[pending uncertain submitted rejected].include?(receipt["status"]) &&
745
+ receipt["fingerprint"].is_a?(String)
746
+ raise ProviderFailure, "invalid_receipt_state"
747
+ end
748
+ end
749
+ data["entries"]
750
+ rescue JSON::ParserError
751
+ raise ProviderFailure, "invalid_receipt_state"
752
+ end
753
+ end
754
+ end
755
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module OmpWorklog
4
- VERSION = "0.14.6"
4
+ VERSION = "0.15.0"
5
5
  end
data/lib/omp_worklog.rb CHANGED
@@ -28,13 +28,15 @@ module OmpWorklog
28
28
  class Error < StandardError; end
29
29
 
30
30
  class CLI
31
- def self.run(arguments, output: $stdout, error: $stderr, client: nil)
31
+ def self.run(arguments, output: $stdout, error: $stderr, client: nil, input: $stdin)
32
32
  command, *command_arguments = arguments
33
33
  case command
34
34
  when "time-off"
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 "work-entry-batch"
39
+ BatchCLI.run(command_arguments, input:, output:, error:, client:)
38
40
  when "-h", "--help"
39
41
  output.puts usage
40
42
  0
@@ -42,7 +44,7 @@ module OmpWorklog
42
44
  output.puts VERSION
43
45
  0
44
46
  else
45
- error.puts "Error: choose time-off or work-entry"
47
+ error.puts "Error: choose time-off, work-entry, or work-entry-batch"
46
48
  error.puts usage
47
49
  1
48
50
  end
@@ -55,10 +57,12 @@ module OmpWorklog
55
57
  omp-worklog time-off FROM TO --project-id ID --task-id ID [options]
56
58
  omp-worklog work-entry DATE --project NAME --task NAME --hours HOURS --notes NOTES [options]
57
59
  omp-worklog work-entry DATE --project-id ID --task-id ID --hours HOURS --notes NOTES [options]
60
+ omp-worklog work-entry-batch [--dry-run] [--state-dir DIR] < review-batch.json
58
61
 
59
62
  Commands:
60
- time-off Create one entry per local business day in a date range.
61
- work-entry Create one reviewed ordinary-work entry.
63
+ time-off Create one entry per local business day in a date range.
64
+ work-entry Create one reviewed ordinary-work entry.
65
+ work-entry-batch Preflight and submit one approved reviewed batch.
62
66
  USAGE
63
67
  end
64
68
  end
@@ -180,3 +184,4 @@ module OmpWorklog
180
184
  end
181
185
 
182
186
  require "omp_worklog/work_entry_cli"
187
+ require "omp_worklog/batch_cli"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: omp-worklog
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.14.6
4
+ version: 0.15.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Marlen Brunner
@@ -65,6 +65,7 @@ files:
65
65
  - bin/release-plugin
66
66
  - bin/verify-plugin-release
67
67
  - lib/omp_worklog.rb
68
+ - lib/omp_worklog/batch_cli.rb
68
69
  - lib/omp_worklog/version.rb
69
70
  - lib/omp_worklog/work_entry_cli.rb
70
71
  - omp-worklog.rb
@@ -91,5 +92,5 @@ requirements: []
91
92
  rubygems_version: 3.5.22
92
93
  signing_key:
93
94
  specification_version: 4
94
- summary: OMP Project Time work logs with an optional manual Harvest destination.
95
+ summary: Concurrent-project work logs with reviewed, safe Harvest batch filing.
95
96
  test_files: []