omp-worklog 0.14.6 → 0.16.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 +4 -4
- data/README.md +34 -5
- data/lib/omp_worklog/batch_cli.rb +755 -0
- data/lib/omp_worklog/version.rb +1 -1
- data/lib/omp_worklog.rb +9 -4
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f9500ecafd77741aaa3fbcdd272941bb090c5976dc09326f6d1543ad3d33697e
|
|
4
|
+
data.tar.gz: a03ade82220b2fa963e4de0021e5da5e76fe26a20a8d1be652ed34f3c33946ad
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3c53f04bc1f1b297ddb6ec211549b4ef153e5fb5a2ebd724473305da9564c5e34a9f303beac96821d01a959f1b0c3ef5b2eda5b089502d913d7bec546945abb4
|
|
7
|
+
data.tar.gz: 2aa849d41309588e123aa19a8eef3e2b4b7d99ecda14cc7cfd36efc267f14bd4f2b3dd6d4aede90517d5111835424ad4dde8f7bf1653980841502619ad63f45a
|
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
|
|
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,25 @@ 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 `
|
|
80
|
+
Type `/worklog ` to discover review, timesheet, stats, plan, and submit. Date aliases are completed contextually. After `--project`, timesheet completion lists human-active projects; review, 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
|
+
At the end of a workday, run `/worklog review`. It opens today's worklog inside OMP: edit each project's duration, destination, and factual note, then choose **Mark reviewed** or **Exclude entry**. When all retained entries are ready, choose **Submit to Harvest** once. No JSON editing or path copying is required. Use `/worklog review yesterday` for another day.
|
|
85
|
+
|
|
86
|
+
**Save and close** preserves your private draft for the next run. **Refresh evidence** rebuilds the snapshot after confirmation and retains the prior draft privately. Review does not submit anything until you explicitly choose Submit.
|
|
87
|
+
|
|
88
|
+
```text
|
|
89
|
+
/worklog stats 2026-10-05 2026-10-18
|
|
90
|
+
/worklog stats today --until 12:00
|
|
91
|
+
/worklog plan yesterday
|
|
92
|
+
/worklog submit "PATH-PRINTED-BY-PLAN" --dry-run
|
|
93
|
+
/worklog submit "PATH-PRINTED-BY-PLAN"
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
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.
|
|
97
|
+
|
|
98
|
+
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
99
|
|
|
82
100
|
## OMP settings
|
|
83
101
|
|
|
@@ -88,6 +106,7 @@ Worklog resolves persisted user settings and project overrides through the suppo
|
|
|
88
106
|
- `command`: direct path to the `omp-worklog` executable.
|
|
89
107
|
- `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
108
|
- `projectTimeMappings`: optional JSON mapping of exact local projects to Harvest project/task names; defaults to `{}`.
|
|
109
|
+
- `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
110
|
|
|
92
111
|
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
112
|
|
|
@@ -113,14 +132,24 @@ For independent comparison with manually recorded Harvest entries, follow the [p
|
|
|
113
132
|
|
|
114
133
|
`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
134
|
|
|
116
|
-
|
|
117
|
-
|
|
118
135
|
## Migration from Harvest Worklog
|
|
119
136
|
|
|
120
137
|
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
138
|
|
|
122
139
|
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
140
|
|
|
141
|
+
## Changes in 0.16.0
|
|
142
|
+
|
|
143
|
+
- Review and submit today's worklog inside OMP with `/worklog review [DATE]`.
|
|
144
|
+
- Edit or exclude entries through native dialogs, save/resume private daily drafts, and refresh evidence without losing prior edits.
|
|
145
|
+
- Display readable filing outcomes while retaining the existing batch approval and recovery protections.
|
|
146
|
+
|
|
147
|
+
## Changes in 0.15.0
|
|
148
|
+
|
|
149
|
+
- Add daily/project human-active and AI elapsed reports with a final-day cutoff.
|
|
150
|
+
- Prepare private concurrent-project review batches with within-project deduplication and five-minute same-session response grace.
|
|
151
|
+
- File explicitly approved batches with user-scoped preflight, stable identities, private receipts, and safe partial/uncertain-result handling.
|
|
152
|
+
|
|
124
153
|
## Changes in 0.14.6
|
|
125
154
|
|
|
126
155
|
- Activate persisted settings through the supported host CLI before registering commands, completion, and tools.
|
|
@@ -143,7 +172,7 @@ Version `0.14.3` renames the OMP slash command from `/omp-worklog` to `/worklog`
|
|
|
143
172
|
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
173
|
|
|
145
174
|
```bash
|
|
146
|
-
ruby
|
|
175
|
+
npm run test:ruby
|
|
147
176
|
npm run test:omp
|
|
148
177
|
bin/omp-worklog --help
|
|
149
178
|
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
|
data/lib/omp_worklog/version.rb
CHANGED
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
|
|
61
|
-
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.
|
|
4
|
+
version: 0.16.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:
|
|
95
|
+
summary: Concurrent-project work logs with reviewed, safe Harvest batch filing.
|
|
95
96
|
test_files: []
|