moco-ruby 1.0.0.alpha → 1.0.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/CHANGELOG.md +43 -5
- data/Gemfile +1 -0
- data/Gemfile.lock +3 -1
- data/README.md +28 -2
- data/lib/moco/client.rb +2 -2
- data/lib/moco/collection_proxy.rb +10 -0
- data/lib/moco/connection.rb +8 -2
- data/lib/moco/entities/activity.rb +6 -1
- data/lib/moco/entities/base_entity.rb +14 -5
- data/lib/moco/entities/expense.rb +10 -2
- data/lib/moco/entities/holiday.rb +1 -1
- data/lib/moco/entities/project.rb +24 -15
- data/lib/moco/entities/web_hook.rb +1 -1
- data/lib/moco/entity_collection.rb +3 -3
- data/lib/moco/nested_collection_proxy.rb +4 -1
- data/lib/moco/sync.rb +299 -65
- data/lib/moco/version.rb +1 -1
- data/lib/moco.rb +5 -3
- data/moco.gemspec +36 -0
- data/sync_activity.rb +10 -4
- metadata +5 -4
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 94fd15c735a242e23f7a1e20dd49408d12dbfbb21f1665ca64bfd85ba3251cae
|
4
|
+
data.tar.gz: 710f5ce6be51b2c363c6d2b3df871d755e5b21e90e9ad53cbb535ac249b46b6f
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 272eede87d5a02636ca02354ea0ef592f0d21662378fb6817b68cc6fa73e66922b43623f6ac5da5c4c76ac3db9c6ebaee12a340fcc9920df93104303f96668fd
|
7
|
+
data.tar.gz: 12f55e014641e51ecf2f666c04bff0a5f330ae68b91cf63e70060b6f85e5fc759eed517854060190651581a7ffba5bfa8345fdd25b8e2e59a96193a32cdece9a
|
data/CHANGELOG.md
CHANGED
@@ -1,7 +1,43 @@
|
|
1
|
-
# Changelog
|
1
|
+
# # Changelog
|
2
2
|
|
3
3
|
## [Unreleased]
|
4
4
|
|
5
|
+
## [1.0.0] - 2025-10-08
|
6
|
+
|
7
|
+
### Fixed
|
8
|
+
- Fixed Project `leader` and `co_leader` associations to return User objects instead of Hashes
|
9
|
+
- Fixed Expense associations to use proper `association()` method for embedded objects
|
10
|
+
- Fixed nested resource proxy caching issue - tasks and expenses now always return fresh data
|
11
|
+
- Fixed `NestedCollectionProxy` path construction for expenses (was double-prefixing with "projects/")
|
12
|
+
- Fixed `entity_path` warnings by converting instance methods to class methods in Holiday and WebHook classes
|
13
|
+
- Fixed embedded entity conversion for leader, co_leader, and user associations in BaseEntity
|
14
|
+
|
15
|
+
### Added
|
16
|
+
- Added comprehensive test suite using test-unit framework:
|
17
|
+
- `test/test_comprehensive.rb` - 27 integration tests covering all CRUD operations
|
18
|
+
- `test/test_holidays_expenses.rb` - 9 tests for holidays and expenses (nested resources)
|
19
|
+
- `test/test_v2_api.rb` - 4 unit tests with mocked API responses
|
20
|
+
- `test/test_helper.rb` - Shared test configuration
|
21
|
+
- Added `Project#expenses` method for nested expense access
|
22
|
+
- Added proper entity type mappings for `:leader`, `:co_leader`, and `:user` in BaseEntity
|
23
|
+
- Added dotenv gem as development dependency for test environment configuration
|
24
|
+
|
25
|
+
### Changed
|
26
|
+
- Moved all `require_relative` statements out of methods and into file-level requires
|
27
|
+
- Improved load order in `lib/moco.rb` - core classes now load before entities
|
28
|
+
- Updated `Expense` entity to use `association()` method for project and user relationships
|
29
|
+
- Refactored nested resource access in Project class to return fresh proxies instead of cached ones
|
30
|
+
- Enhanced `NestedCollectionProxy` to properly handle nested resource paths without double-prefixing
|
31
|
+
|
32
|
+
### Removed
|
33
|
+
- Removed manual test scripts (converted to proper test-unit tests)
|
34
|
+
|
35
|
+
## [1.0.0.beta] - 2025-04-10
|
36
|
+
|
37
|
+
### Fixed
|
38
|
+
- Fixed activity synchronization to properly identify existing activities in target system
|
39
|
+
- Added remote_id accessor to Activity class to prevent duplicate activity creation
|
40
|
+
|
5
41
|
## [1.0.0.alpha] - 2025-04-10
|
6
42
|
|
7
43
|
### Added
|
@@ -10,7 +46,7 @@
|
|
10
46
|
- Supports proper path construction for nested API endpoints
|
11
47
|
- Implements `destroy_all` method for bulk deletion of nested resources
|
12
48
|
|
13
|
-
## [1.0.0] - 2025-04-10
|
49
|
+
## [1.0.0.alpha-initial] - 2025-04-10
|
14
50
|
|
15
51
|
### Added
|
16
52
|
- Implemented ActiveRecord-style query interface (`where`, `find`, `find_by`, `first`, `all`, `each`) via `CollectionProxy`.
|
@@ -89,9 +125,11 @@
|
|
89
125
|
## [0.1.0] - 2024-02-27
|
90
126
|
- Initial release
|
91
127
|
|
92
|
-
[Unreleased]: https://github.com/starsong-consulting/moco-ruby/compare/v1.0.0
|
93
|
-
[1.0.0
|
94
|
-
[1.0.0]: https://github.com/starsong-consulting/moco-ruby/compare/
|
128
|
+
[Unreleased]: https://github.com/starsong-consulting/moco-ruby/compare/v1.0.0...HEAD
|
129
|
+
[1.0.0]: https://github.com/starsong-consulting/moco-ruby/compare/v1.0.0.beta...v1.0.0
|
130
|
+
[1.0.0.beta]: https://github.com/starsong-consulting/moco-ruby/compare/v1.0.0.alpha...v1.0.0.beta
|
131
|
+
[1.0.0.alpha]: https://github.com/starsong-consulting/moco-ruby/compare/v1.0.0.alpha-initial...v1.0.0.alpha
|
132
|
+
[1.0.0.alpha-initial]: https://github.com/starsong-consulting/moco-ruby/compare/v0.1.2...v1.0.0.alpha-initial
|
95
133
|
[0.1.2]: https://github.com/starsong-consulting/moco-ruby/compare/v0.1.1...v0.1.2
|
96
134
|
[0.1.1]: https://github.com/starsong-consulting/moco-ruby/compare/v0.1.0...v0.1.1
|
97
135
|
[0.1.0]: https://github.com/starsong-consulting/moco-ruby/releases/tag/v0.1.0
|
data/Gemfile
CHANGED
data/Gemfile.lock
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
PATH
|
2
2
|
remote: .
|
3
3
|
specs:
|
4
|
-
moco-ruby (1.0.0
|
4
|
+
moco-ruby (1.0.0)
|
5
5
|
activesupport (~> 7.0)
|
6
6
|
faraday (~> 2.9.0)
|
7
7
|
fuzzy_match (~> 2.1.0)
|
@@ -32,6 +32,7 @@ GEM
|
|
32
32
|
crack (1.0.0)
|
33
33
|
bigdecimal
|
34
34
|
rexml
|
35
|
+
dotenv (2.8.1)
|
35
36
|
drb (2.2.1)
|
36
37
|
faraday (2.9.2)
|
37
38
|
faraday-net_http (>= 2.0, < 3.2)
|
@@ -94,6 +95,7 @@ PLATFORMS
|
|
94
95
|
arm64-darwin-23
|
95
96
|
|
96
97
|
DEPENDENCIES
|
98
|
+
dotenv (~> 2.8)
|
97
99
|
moco-ruby!
|
98
100
|
rake (~> 13.0)
|
99
101
|
rubocop (~> 1.21)
|
data/README.md
CHANGED
@@ -232,9 +232,35 @@ Usage: sync_activity.rb [options] source_subdomain target_subdomain
|
|
232
232
|
|
233
233
|
## Development
|
234
234
|
|
235
|
-
After checking out the repo, run `bin/setup` to install dependencies.
|
235
|
+
After checking out the repo, run `bin/setup` to install dependencies.
|
236
236
|
|
237
|
-
|
237
|
+
### Running Tests
|
238
|
+
|
239
|
+
The gem includes a comprehensive test suite with both unit tests (mocked) and integration tests (live API):
|
240
|
+
|
241
|
+
```bash
|
242
|
+
# Run all tests
|
243
|
+
ruby test/test_v2_api.rb # Unit tests (mocked, fast)
|
244
|
+
ruby test/test_comprehensive.rb # Integration tests (requires .env)
|
245
|
+
ruby test/test_holidays_expenses.rb # Holidays & Expenses tests (requires .env)
|
246
|
+
|
247
|
+
# Or run individually
|
248
|
+
ruby test/test_v2_api.rb
|
249
|
+
```
|
250
|
+
|
251
|
+
For integration tests, create a `.env` file with your test instance credentials:
|
252
|
+
```
|
253
|
+
MOCO_API_TEST_SUBDOMAIN=your-test-subdomain
|
254
|
+
MOCO_API_TEST_API_KEY=your-test-api-key
|
255
|
+
```
|
256
|
+
|
257
|
+
**Note:** The MOCO API has rate limits (120 requests per 2 minutes on standard plans). Integration tests make real API calls.
|
258
|
+
|
259
|
+
### Installation
|
260
|
+
|
261
|
+
To install this gem onto your local machine, run `bundle exec rake install`.
|
262
|
+
|
263
|
+
To release a new version, update the version number in `version.rb`, update the `CHANGELOG.md`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
|
238
264
|
|
239
265
|
## Contributing
|
240
266
|
|
data/lib/moco/client.rb
CHANGED
@@ -6,8 +6,8 @@ module MOCO
|
|
6
6
|
class Client
|
7
7
|
attr_reader :connection
|
8
8
|
|
9
|
-
def initialize(subdomain:, api_key:)
|
10
|
-
@connection = Connection.new(self, subdomain, api_key)
|
9
|
+
def initialize(subdomain:, api_key:, debug: false)
|
10
|
+
@connection = Connection.new(self, subdomain, api_key, debug: debug)
|
11
11
|
@collections = {}
|
12
12
|
end
|
13
13
|
|
@@ -54,6 +54,13 @@ module MOCO
|
|
54
54
|
self
|
55
55
|
end
|
56
56
|
|
57
|
+
# Modifies the base path to fetch assigned resources. Returns self.
|
58
|
+
def assigned
|
59
|
+
# Ensure this is only called once or handle idempotency if needed
|
60
|
+
@base_path += "/assigned"
|
61
|
+
self
|
62
|
+
end
|
63
|
+
|
57
64
|
# --- Methods Triggering API Call ---
|
58
65
|
|
59
66
|
# Fetches all records matching the current filters.
|
@@ -130,6 +137,9 @@ module MOCO
|
|
130
137
|
# MOCO API might use 'per_page' instead of 'limit' for pagination control
|
131
138
|
# Adjust if necessary based on API docs. Assuming 'limit' works for now.
|
132
139
|
|
140
|
+
# Filter out nil values before sending to avoid empty params like ?from&to=
|
141
|
+
query_params.compact!
|
142
|
+
|
133
143
|
response = client.get(@base_path, query_params)
|
134
144
|
@records = wrap_response(response) # wrap_response should return an Array here
|
135
145
|
@loaded = true
|
data/lib/moco/connection.rb
CHANGED
@@ -7,12 +7,13 @@ module MOCO
|
|
7
7
|
# Handles HTTP communication with the MOCO API
|
8
8
|
# Responsible for building API requests and converting responses to entity objects
|
9
9
|
class Connection
|
10
|
-
attr_reader :client, :subdomain, :api_key
|
10
|
+
attr_reader :client, :subdomain, :api_key, :debug
|
11
11
|
|
12
|
-
def initialize(client, subdomain, api_key)
|
12
|
+
def initialize(client, subdomain, api_key, debug: false)
|
13
13
|
@client = client
|
14
14
|
@subdomain = subdomain
|
15
15
|
@api_key = api_key
|
16
|
+
@debug = debug
|
16
17
|
@conn = Faraday.new do |f|
|
17
18
|
f.request :json
|
18
19
|
f.response :json
|
@@ -25,6 +26,11 @@ module MOCO
|
|
25
26
|
# These methods send the request and return the raw parsed JSON response body.
|
26
27
|
%w[get post put patch delete].each do |http_method|
|
27
28
|
define_method(http_method) do |path, params = {}|
|
29
|
+
# Log URL if debug is enabled
|
30
|
+
if @debug
|
31
|
+
full_url = @conn.build_url(path, params).to_s
|
32
|
+
warn "[DEBUG] Fetching URL: #{http_method.upcase} #{full_url}"
|
33
|
+
end
|
28
34
|
response = @conn.send(http_method, path, params)
|
29
35
|
|
30
36
|
# Raise an error for non-successful responses
|
@@ -89,8 +89,13 @@ module MOCO
|
|
89
89
|
end
|
90
90
|
end
|
91
91
|
|
92
|
+
# Access the remote_id attribute
|
93
|
+
def remote_id
|
94
|
+
attributes[:remote_id]
|
95
|
+
end
|
96
|
+
|
92
97
|
def to_s
|
93
|
-
"#{date} - #{hours}h - #{project&.name} - #{task&.name} - #{description}"
|
98
|
+
"#{attributes[:date]} - #{attributes[:hours]}h - #{project&.name} - #{task&.name} - #{attributes[:description]}"
|
94
99
|
end
|
95
100
|
end
|
96
101
|
end
|
@@ -29,6 +29,11 @@ module MOCO
|
|
29
29
|
define_attribute_methods
|
30
30
|
end
|
31
31
|
|
32
|
+
# Provides a basic string representation (can be overridden by subclasses).
|
33
|
+
def to_s
|
34
|
+
"#{self.class.name.split("::").last} ##{id}"
|
35
|
+
end
|
36
|
+
|
32
37
|
# Returns the entity's ID.
|
33
38
|
def id
|
34
39
|
attributes[:id] || attributes["id"]
|
@@ -185,9 +190,11 @@ module MOCO
|
|
185
190
|
# Return cached collection if available
|
186
191
|
return @_association_cache[association_name] if @_association_cache.key?(association_name)
|
187
192
|
|
188
|
-
# If the association data is already in attributes and is an array of entities
|
193
|
+
# If the association data is already in attributes and is an array of entities
|
194
|
+
# AND this is NOT a nested resource, use it directly
|
195
|
+
# For nested resources, we always create a proxy to ensure CRUD operations work
|
189
196
|
association_data = attributes[association_name]
|
190
|
-
if association_data.is_a?(Array) && association_data.all? { |item| item.is_a?(MOCO::BaseEntity) }
|
197
|
+
if !nested && association_data.is_a?(Array) && association_data.all? { |item| item.is_a?(MOCO::BaseEntity) }
|
191
198
|
return @_association_cache[association_name] = association_data
|
192
199
|
end
|
193
200
|
|
@@ -201,7 +208,6 @@ module MOCO
|
|
201
208
|
# Check if this is a nested resource
|
202
209
|
if nested
|
203
210
|
# For nested resources, create a NestedCollectionProxy
|
204
|
-
require_relative "../nested_collection_proxy"
|
205
211
|
@_association_cache[association_name] = MOCO::NestedCollectionProxy.new(
|
206
212
|
client,
|
207
213
|
self,
|
@@ -273,9 +279,12 @@ module MOCO
|
|
273
279
|
|
274
280
|
# Infer type from the key_hint if :type attribute is missing
|
275
281
|
if type_name.nil? && key_hint
|
276
|
-
# Special
|
277
|
-
type_name =
|
282
|
+
# Special cases: map certain keys to specific entity classes
|
283
|
+
type_name = case key_hint
|
284
|
+
when :customer
|
278
285
|
"Company"
|
286
|
+
when :leader, :co_leader, :user
|
287
|
+
"User"
|
279
288
|
else
|
280
289
|
# General case: singularize the key hint (e.g., :tasks -> "task")
|
281
290
|
ActiveSupport::Inflector.singularize(key_hint.to_s)
|
@@ -4,6 +4,12 @@ module MOCO
|
|
4
4
|
# Represents a MOCO expense
|
5
5
|
# Provides methods for expense-specific operations and associations
|
6
6
|
class Expense < BaseEntity
|
7
|
+
# Override entity_path to use the global expenses endpoint
|
8
|
+
# Note: Expenses can also be accessed via projects/{id}/expenses
|
9
|
+
def self.entity_path
|
10
|
+
"projects/expenses"
|
11
|
+
end
|
12
|
+
|
7
13
|
# Class methods for bulk operations
|
8
14
|
def self.disregard(client, expense_ids:)
|
9
15
|
client.post("projects/expenses/disregard", { expense_ids: })
|
@@ -15,11 +21,13 @@ module MOCO
|
|
15
21
|
|
16
22
|
# Associations
|
17
23
|
def project
|
18
|
-
|
24
|
+
# Use the association method which handles embedded objects
|
25
|
+
association(:project, "Project")
|
19
26
|
end
|
20
27
|
|
21
28
|
def user
|
22
|
-
|
29
|
+
# Use the association method which handles embedded objects
|
30
|
+
association(:user, "User")
|
23
31
|
end
|
24
32
|
|
25
33
|
def to_s
|
@@ -7,29 +7,38 @@ module MOCO
|
|
7
7
|
association(:customer, "Company")
|
8
8
|
end
|
9
9
|
|
10
|
+
def leader
|
11
|
+
# Use the association method to fetch the leader
|
12
|
+
association(:leader, "User")
|
13
|
+
end
|
14
|
+
|
15
|
+
def co_leader
|
16
|
+
# Use the association method to fetch the co_leader
|
17
|
+
association(:co_leader, "User")
|
18
|
+
end
|
19
|
+
|
10
20
|
# Fetches activities associated with this project.
|
11
21
|
def activities
|
12
22
|
# Use the has_many method to fetch activities
|
13
23
|
has_many(:activities)
|
14
24
|
end
|
15
25
|
|
26
|
+
# Fetches expenses associated with this project.
|
27
|
+
def expenses
|
28
|
+
# Don't cache the proxy - create a fresh one each time
|
29
|
+
# This ensures we get fresh data when expenses are created/updated/deleted
|
30
|
+
MOCO::NestedCollectionProxy.new(client, self, :expenses, "Expense")
|
31
|
+
end
|
32
|
+
|
16
33
|
# Fetches tasks associated with this project.
|
17
34
|
def tasks
|
18
|
-
#
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
proxy.instance_variable_set(:@records, attributes[:tasks])
|
26
|
-
proxy.instance_variable_set(:@loaded, true)
|
27
|
-
proxy
|
28
|
-
end
|
29
|
-
else
|
30
|
-
# Otherwise, use has_many with nested=true
|
31
|
-
has_many(:tasks, nil, nil, true)
|
32
|
-
end
|
35
|
+
# Don't cache the proxy - create a fresh one each time
|
36
|
+
# This ensures we get fresh data when tasks are created/updated/deleted
|
37
|
+
MOCO::NestedCollectionProxy.new(client, self, :tasks, "Task")
|
38
|
+
end
|
39
|
+
|
40
|
+
def to_s
|
41
|
+
"Project #{identifier} \"#{name}\" (#{id})"
|
33
42
|
end
|
34
43
|
|
35
44
|
def active?
|
@@ -14,8 +14,8 @@ module MOCO
|
|
14
14
|
@entity_class_name = entity_class_name
|
15
15
|
end
|
16
16
|
|
17
|
-
def all
|
18
|
-
collection.all
|
17
|
+
def all
|
18
|
+
collection.all
|
19
19
|
end
|
20
20
|
|
21
21
|
def find(id)
|
@@ -31,7 +31,7 @@ module MOCO
|
|
31
31
|
end
|
32
32
|
|
33
33
|
def each(&)
|
34
|
-
|
34
|
+
collection.each(&)
|
35
35
|
end
|
36
36
|
|
37
37
|
def first
|
@@ -12,9 +12,12 @@ module MOCO
|
|
12
12
|
end
|
13
13
|
|
14
14
|
# Override determine_base_path to include the parent's path
|
15
|
+
# For nested resources, we ignore any custom entity_path and just use simple pluralization
|
15
16
|
def determine_base_path(path_or_entity_name)
|
16
17
|
parent_type = ActiveSupport::Inflector.underscore(parent.class.name.split("::").last)
|
17
|
-
|
18
|
+
# Use simple tableized name, not entity_path (which might include 'projects/' prefix)
|
19
|
+
nested_path = ActiveSupport::Inflector.tableize(path_or_entity_name.to_s)
|
20
|
+
"#{parent_type.pluralize}/#{parent.id}/#{nested_path}"
|
18
21
|
end
|
19
22
|
|
20
23
|
# Create a new entity in this nested collection
|
data/lib/moco/sync.rb
CHANGED
@@ -7,7 +7,7 @@ module MOCO
|
|
7
7
|
# Match and map projects and tasks between MOCO instances and sync activities
|
8
8
|
class Sync
|
9
9
|
attr_reader :project_mapping, :task_mapping, :source_projects, :target_projects
|
10
|
-
attr_accessor :project_match_threshold, :task_match_threshold, :dry_run
|
10
|
+
attr_accessor :project_match_threshold, :task_match_threshold, :dry_run, :debug
|
11
11
|
|
12
12
|
def initialize(source_client, target_client, **args)
|
13
13
|
@source = source_client
|
@@ -16,6 +16,7 @@ module MOCO
|
|
16
16
|
@task_match_threshold = args.fetch(:task_match_threshold, 0.45)
|
17
17
|
@filters = args.fetch(:filters, {})
|
18
18
|
@dry_run = args.fetch(:dry_run, false)
|
19
|
+
@debug = args.fetch(:debug, false)
|
19
20
|
|
20
21
|
@project_mapping = {}
|
21
22
|
@task_mapping = {}
|
@@ -28,77 +29,159 @@ module MOCO
|
|
28
29
|
def sync(&callbacks)
|
29
30
|
results = []
|
30
31
|
|
31
|
-
|
32
|
-
|
32
|
+
source_activity_filters = @filters.fetch(:source, {})
|
33
|
+
source_activities_r = @source.activities.where(source_activity_filters).all
|
34
|
+
debug_log "Fetched #{source_activities_r.size} source activities"
|
35
|
+
|
36
|
+
# Log source activities for debugging
|
37
|
+
debug_log "Source activities:"
|
38
|
+
source_activities_r.each do |activity|
|
39
|
+
debug_log " Source Activity: #{activity.id}, Date: #{activity.date}, Project: #{activity.project&.id} (#{activity.project&.name}), Task: #{activity.task&.id} (#{activity.task&.name}), Hours: #{activity.hours}, Description: #{activity.description}, Remote ID: #{activity.remote_id}"
|
40
|
+
|
41
|
+
# Also log the expected target activity for each source activity
|
42
|
+
begin
|
43
|
+
expected = get_expected_target_activity(activity)
|
44
|
+
if expected
|
45
|
+
project_id = expected.project&.id rescue "N/A"
|
46
|
+
task_id = expected.task&.id rescue "N/A"
|
47
|
+
remote_id = expected.instance_variable_get(:@attributes)[:remote_id] rescue "N/A"
|
48
|
+
debug_log " Expected Target: Project: #{project_id}, Task: #{task_id}, Remote ID: #{remote_id}"
|
49
|
+
end
|
50
|
+
rescue => e
|
51
|
+
debug_log " Error getting expected target: #{e.message}"
|
52
|
+
end
|
53
|
+
end
|
33
54
|
|
55
|
+
target_activity_filters = @filters.fetch(:target, {})
|
56
|
+
target_activities_r = @target.activities.where(target_activity_filters).all
|
57
|
+
debug_log "Fetched #{target_activities_r.size} target activities"
|
58
|
+
|
59
|
+
# Log target activities for debugging
|
60
|
+
debug_log "Target activities:"
|
61
|
+
target_activities_r.each do |activity|
|
62
|
+
debug_log " Target Activity: #{activity.id}, Date: #{activity.date}, Project: #{activity.project&.id} (#{activity.project&.name}), Task: #{activity.task&.id} (#{activity.task&.name}), Hours: #{activity.hours}, Description: #{activity.description}, Remote ID: #{activity.remote_id}"
|
63
|
+
end
|
64
|
+
|
65
|
+
# Group activities by date and then by project_id for consistent lookups
|
34
66
|
source_activities_grouped = source_activities_r.group_by(&:date).transform_values do |activities|
|
35
|
-
activities.group_by
|
67
|
+
activities.group_by { |a| a.project&.id } # Group by project ID
|
36
68
|
end
|
37
69
|
target_activities_grouped = target_activities_r.group_by(&:date).transform_values do |activities|
|
38
|
-
activities.group_by
|
70
|
+
activities.group_by { |a| a.project&.id } # Group by project ID
|
39
71
|
end
|
40
72
|
|
41
73
|
used_source_activities = []
|
42
74
|
used_target_activities = []
|
43
75
|
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
76
|
+
debug_log "Starting main sync loop..."
|
77
|
+
source_activities_grouped.each do |date, activities_by_project_id|
|
78
|
+
debug_log "Processing date: #{date}"
|
79
|
+
activities_by_project_id.each do |source_project_id, source_activities|
|
80
|
+
debug_log " Processing source project ID: #{source_project_id} (#{source_activities.count} activities)"
|
81
|
+
# Find the corresponding target project ID using the mapping
|
82
|
+
target_project_object = @project_mapping[source_project_id]
|
83
|
+
unless target_project_object
|
84
|
+
debug_log " Skipping - Source project ID #{source_project_id} not mapped."
|
85
|
+
next
|
86
|
+
end
|
87
|
+
|
88
|
+
target_project_id = target_project_object.id
|
89
|
+
# Fetch target activities using the target project ID
|
90
|
+
target_activities = target_activities_grouped.fetch(date, {}).fetch(target_project_id, [])
|
91
|
+
debug_log " Found #{target_activities.count} target activities for target project ID: #{target_project_id}"
|
92
|
+
|
93
|
+
if source_activities.empty? || target_activities.empty?
|
94
|
+
debug_log " Skipping - No source or target activities for this date/project pair."
|
95
|
+
next
|
96
|
+
end
|
48
97
|
|
49
98
|
matches = calculate_matches(source_activities, target_activities)
|
99
|
+
debug_log " Calculated #{matches.count} potential matches."
|
50
100
|
matches.sort_by! { |match| -match[:score] }
|
51
101
|
|
102
|
+
debug_log " Entering matches loop..."
|
52
103
|
matches.each do |match|
|
53
104
|
source_activity, target_activity = match[:activity]
|
54
105
|
score = match[:score]
|
106
|
+
debug_log " Match Pair: Score=#{score}, Source=#{source_activity.id}, Target=#{target_activity.id}"
|
55
107
|
|
56
|
-
|
108
|
+
if used_source_activities.include?(source_activity) || used_target_activities.include?(target_activity)
|
109
|
+
debug_log " Skipping match pair - already used: Source used=#{used_source_activities.include?(source_activity)}, Target used=#{used_target_activities.include?(target_activity)}"
|
110
|
+
next
|
111
|
+
end
|
57
112
|
|
58
|
-
best_score = score
|
113
|
+
best_score = score # Since we sorted, this is the best score for this unused pair
|
59
114
|
best_match = target_activity
|
60
115
|
expected_target_activity = get_expected_target_activity(source_activity)
|
116
|
+
debug_log " Processing best score #{best_score} for Source=#{source_activity.id}"
|
61
117
|
|
62
118
|
case best_score
|
63
119
|
when 100
|
120
|
+
debug_log " Case 100: Equal"
|
64
121
|
# 100 - perfect match found, nothing needs doing
|
65
122
|
callbacks&.call(:equal, source_activity, expected_target_activity)
|
123
|
+
# Mark both as used
|
124
|
+
debug_log " Marking Source=#{source_activity.id} and Target=#{target_activity.id} as used."
|
125
|
+
used_source_activities << source_activity
|
126
|
+
used_target_activities << target_activity
|
66
127
|
when 60...100
|
128
|
+
debug_log " Case 60-99: Update"
|
67
129
|
# >=60 <100 - match with some differences
|
68
130
|
expected_target_activity.to_h.except(:id, :user, :customer).each do |k, v|
|
131
|
+
debug_log " Updating attribute #{k} on Target=#{target_activity.id}"
|
69
132
|
best_match.send("#{k}=", v)
|
70
133
|
end
|
71
134
|
callbacks&.call(:update, source_activity, best_match)
|
72
135
|
unless @dry_run
|
73
|
-
|
136
|
+
debug_log " Executing API update for Target=#{target_activity.id}"
|
137
|
+
results << @target.activities.update(best_match.id, best_match.attributes) # Pass ID and attributes
|
74
138
|
callbacks&.call(:updated, source_activity, best_match, results.last)
|
75
139
|
end
|
140
|
+
# Mark both as used
|
141
|
+
debug_log " Marking Source=#{source_activity.id} and Target=#{target_activity.id} as used."
|
142
|
+
used_source_activities << source_activity
|
143
|
+
used_target_activities << target_activity
|
76
144
|
when 0...60
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
callbacks&.call(:created, source_activity, best_match, results.last)
|
82
|
-
end
|
145
|
+
debug_log " Case 0-59: Low score, doing nothing for this pair."
|
146
|
+
# <60 - Low score for this specific pair. Do nothing here.
|
147
|
+
# Creation is handled later if source_activity remains unused.
|
148
|
+
nil # Explicitly do nothing
|
83
149
|
end
|
84
|
-
|
85
|
-
used_source_activities << source_activity
|
86
|
-
used_target_activities << target_activity
|
150
|
+
# Only mark activities as used if score >= 60 (handled within the case branches above)
|
87
151
|
end
|
152
|
+
debug_log " Finished matches loop."
|
88
153
|
end
|
154
|
+
debug_log " Finished processing project IDs for date #{date}."
|
89
155
|
end
|
156
|
+
debug_log "Finished main sync loop."
|
90
157
|
|
158
|
+
# Second loop: Create source activities that were never used (i.e., had no match >= 60)
|
159
|
+
debug_log "Starting creation loop..."
|
91
160
|
source_activities_r.each do |source_activity|
|
92
|
-
|
93
|
-
|
161
|
+
if used_source_activities.include?(source_activity)
|
162
|
+
debug_log " Skipping creation for Source=#{source_activity.id} - already used."
|
163
|
+
next
|
164
|
+
end
|
165
|
+
# Use safe navigation in case project is nil
|
166
|
+
source_project_id = source_activity.project&.id
|
167
|
+
unless @project_mapping[source_project_id]
|
168
|
+
debug_log " Skipping creation for Source=#{source_activity.id} - project #{source_project_id} not mapped."
|
169
|
+
next
|
170
|
+
end
|
94
171
|
|
172
|
+
debug_log " Processing creation for Source=#{source_activity.id}"
|
95
173
|
expected_target_activity = get_expected_target_activity(source_activity)
|
96
174
|
callbacks&.call(:create, source_activity, expected_target_activity)
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
175
|
+
next if @dry_run
|
176
|
+
|
177
|
+
debug_log " Executing API create."
|
178
|
+
# Pass attributes hash to create
|
179
|
+
created_activity = @target.activities.create(expected_target_activity.attributes)
|
180
|
+
results << created_activity
|
181
|
+
# Pass the actual created activity object to the callback
|
182
|
+
callbacks&.call(:created, source_activity, created_activity, results.last)
|
101
183
|
end
|
184
|
+
debug_log "Finished creation loop."
|
102
185
|
|
103
186
|
results
|
104
187
|
end
|
@@ -106,21 +189,54 @@ module MOCO
|
|
106
189
|
|
107
190
|
private
|
108
191
|
|
192
|
+
def debug_log(message)
|
193
|
+
warn "[SYNC DEBUG] #{message}" if @debug
|
194
|
+
end
|
195
|
+
|
109
196
|
def get_expected_target_activity(source_activity)
|
110
|
-
|
111
|
-
|
112
|
-
|
113
|
-
|
197
|
+
# Create a duplicate of the source activity
|
198
|
+
new_activity = source_activity.dup
|
199
|
+
|
200
|
+
# Get the attributes hash
|
201
|
+
attrs = new_activity.instance_variable_get(:@attributes)
|
202
|
+
|
203
|
+
# Store the mapped task and project objects for reference
|
204
|
+
mapped_task = @task_mapping[source_activity.task&.id]
|
205
|
+
mapped_project = @project_mapping[source_activity.project&.id]
|
206
|
+
|
207
|
+
# Set the task_id and project_id attributes instead of the full objects
|
208
|
+
attrs[:task_id] = mapped_task.id if mapped_task
|
209
|
+
attrs[:project_id] = mapped_project.id if mapped_project
|
210
|
+
|
211
|
+
# Set remote_id to the source activity ID for future matching
|
212
|
+
attrs[:remote_id] = source_activity.id.to_s
|
213
|
+
|
214
|
+
# Remove the full objects from the attributes hash
|
215
|
+
attrs.delete(:task)
|
216
|
+
attrs.delete(:project)
|
217
|
+
|
218
|
+
# Return the modified activity
|
219
|
+
new_activity
|
114
220
|
end
|
115
221
|
|
116
222
|
def calculate_matches(source_activities, target_activities)
|
117
223
|
matches = []
|
224
|
+
|
118
225
|
source_activities.each do |source_activity|
|
119
226
|
target_activities.each do |target_activity|
|
120
|
-
|
121
|
-
|
227
|
+
# First check if this is a previously synced activity by comparing IDs directly
|
228
|
+
if target_activity.respond_to?(:remote_id) &&
|
229
|
+
target_activity.remote_id.to_s == source_activity.id.to_s
|
230
|
+
debug_log "Direct match found: target.remote_id=#{target_activity.remote_id} matches source.id=#{source_activity.id}" if @debug
|
231
|
+
matches << { activity: [source_activity, target_activity], score: 100 }
|
232
|
+
else
|
233
|
+
# If no direct match, use the regular scoring method
|
234
|
+
score = score_activity_match(get_expected_target_activity(source_activity), target_activity)
|
235
|
+
matches << { activity: [source_activity, target_activity], score: }
|
236
|
+
end
|
122
237
|
end
|
123
238
|
end
|
239
|
+
|
124
240
|
matches
|
125
241
|
end
|
126
242
|
|
@@ -132,45 +248,145 @@ module MOCO
|
|
132
248
|
[0.0, score].max
|
133
249
|
end
|
134
250
|
|
135
|
-
# rubocop:disable Metrics/AbcSize
|
251
|
+
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
|
136
252
|
def score_activity_match(a, b)
|
253
|
+
# Must be same project
|
137
254
|
return 0 if a.project != b.project
|
138
255
|
|
256
|
+
# Check for exact ID match (for activities that were previously synced)
|
257
|
+
# This is the most important check and overrides all others
|
258
|
+
if a.id.to_s == b.remote_id.to_s || b.id.to_s == a.remote_id.to_s
|
259
|
+
debug_log "Found exact ID match between #{a.id} and #{b.id}" if @debug
|
260
|
+
return 100
|
261
|
+
end
|
262
|
+
|
263
|
+
# Check for exact ID match in remote_id field
|
264
|
+
if a.remote_id.to_s == b.id.to_s || b.remote_id.to_s == a.id.to_s
|
265
|
+
debug_log "Found exact ID match in remote_id: a.remote_id=#{a.remote_id}, b.id=#{b.id}" if @debug
|
266
|
+
return 100
|
267
|
+
end
|
268
|
+
|
269
|
+
# Additional check for remote_id in attributes hash
|
270
|
+
begin
|
271
|
+
a_remote_id = a.instance_variable_get(:@attributes)[:remote_id].to_s rescue nil
|
272
|
+
b_remote_id = b.instance_variable_get(:@attributes)[:remote_id].to_s rescue nil
|
273
|
+
|
274
|
+
if (a_remote_id && !a_remote_id.empty? && a_remote_id == b.id.to_s) ||
|
275
|
+
(b_remote_id && !b_remote_id.empty? && b_remote_id == a.id.to_s)
|
276
|
+
debug_log "Found exact ID match in attributes hash: a.attributes[:remote_id]=#{a_remote_id}, b.id=#{b.id}" if @debug
|
277
|
+
return 100
|
278
|
+
end
|
279
|
+
rescue => e
|
280
|
+
debug_log "Error checking remote_id in attributes: #{e.message}" if @debug
|
281
|
+
end
|
282
|
+
|
283
|
+
# Date comparison - must be same date
|
284
|
+
# Convert to string for comparison to handle different date object types
|
285
|
+
# and normalize format to YYYY-MM-DD
|
286
|
+
debug_log "Raw dates: a.date=#{a.date.inspect} (#{a.date.class}), b.date=#{b.date.inspect} (#{b.date.class})" if @debug
|
287
|
+
|
288
|
+
# Normalize dates to YYYY-MM-DD format
|
289
|
+
a_date = normalize_date(a.date)
|
290
|
+
b_date = normalize_date(b.date)
|
291
|
+
|
292
|
+
debug_log "Normalized dates: a_date=#{a_date}, b_date=#{b_date}" if @debug
|
293
|
+
|
294
|
+
if a_date != b_date
|
295
|
+
debug_log "Date mismatch: #{a_date} vs #{b_date}" if @debug
|
296
|
+
return 0
|
297
|
+
end
|
298
|
+
|
139
299
|
score = 0
|
140
|
-
|
141
|
-
|
142
|
-
|
143
|
-
|
144
|
-
|
145
|
-
|
146
|
-
|
147
|
-
|
300
|
+
|
301
|
+
# Task matching is important (30 points)
|
302
|
+
if a.task&.id == b.task&.id
|
303
|
+
score += 30
|
304
|
+
debug_log "Task match: +30 points" if @debug
|
305
|
+
end
|
306
|
+
|
307
|
+
# Description matching (up to 30 points)
|
308
|
+
if a.description.to_s.strip.empty? && b.description.to_s.strip.empty?
|
309
|
+
# Both empty descriptions - consider it a match for this attribute
|
310
|
+
score += 30
|
311
|
+
debug_log "Empty description match: +30 points" if @debug
|
312
|
+
else
|
313
|
+
# Use fuzzy matching for non-empty descriptions
|
314
|
+
_, description_match_score = FuzzyMatch.new([a.description.to_s]).find_with_score(b.description.to_s)
|
315
|
+
if description_match_score
|
316
|
+
desc_points = (description_match_score * 30.0).to_i
|
317
|
+
score += desc_points
|
318
|
+
debug_log "Description match (#{description_match_score}): +#{desc_points} points" if @debug
|
319
|
+
end
|
320
|
+
end
|
321
|
+
|
322
|
+
# Hours matching (up to 40 points)
|
323
|
+
# Exact hour match gets full points
|
324
|
+
if a.hours == b.hours
|
325
|
+
score += 40
|
326
|
+
debug_log "Exact hours match: +40 points" if @debug
|
327
|
+
else
|
328
|
+
# Otherwise use the clamped difference score
|
329
|
+
hours_points = (clamped_factored_diff_score(a.hours, b.hours) * 40.0).to_i
|
330
|
+
score += hours_points
|
331
|
+
debug_log "Hours similarity (#{a.hours} vs #{b.hours}): +#{hours_points} points" if @debug
|
332
|
+
end
|
333
|
+
|
334
|
+
debug_log "Final score for #{a.id} vs #{b.id}: #{score}" if @debug
|
148
335
|
|
149
336
|
score
|
150
337
|
end
|
151
|
-
|
338
|
+
|
339
|
+
# Helper method to normalize dates to YYYY-MM-DD format
|
340
|
+
def normalize_date(date_value)
|
341
|
+
return nil if date_value.nil?
|
342
|
+
|
343
|
+
date_str = date_value.to_s
|
344
|
+
|
345
|
+
# First try to extract YYYY-MM-DD from ISO format
|
346
|
+
date_str = date_str.split("T").first.strip if date_str.include?("T")
|
347
|
+
|
348
|
+
# Handle different date formats
|
349
|
+
begin
|
350
|
+
# Try to parse as Date object if it's not already in YYYY-MM-DD format
|
351
|
+
date_str = Date.parse(date_str).strftime("%Y-%m-%d") unless date_str =~ /^\d{4}-\d{2}-\d{2}$/
|
352
|
+
rescue StandardError => e
|
353
|
+
debug_log "Error normalizing date '#{date_str}': #{e.message}" if @debug
|
354
|
+
# If parsing fails, return the original string
|
355
|
+
end
|
356
|
+
|
357
|
+
date_str
|
358
|
+
end
|
359
|
+
|
360
|
+
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
|
152
361
|
|
153
362
|
def fetch_assigned_projects
|
154
|
-
|
155
|
-
|
156
|
-
|
157
|
-
|
158
|
-
|
159
|
-
|
160
|
-
|
161
|
-
|
162
|
-
|
163
|
-
|
164
|
-
|
165
|
-
|
166
|
-
|
167
|
-
|
168
|
-
|
169
|
-
|
170
|
-
|
171
|
-
|
172
|
-
|
173
|
-
|
363
|
+
# Use .projects.assigned for the source, standard .projects for the target
|
364
|
+
source_filters = @filters.fetch(:source, {}).merge(active: "true")
|
365
|
+
# Get the proxy, then fetch all results into the instance variable
|
366
|
+
@source_projects = @source.projects.assigned.where(source_filters).all
|
367
|
+
debug_log "Found #{@source_projects.size} source projects:"
|
368
|
+
@source_projects.each do |project|
|
369
|
+
debug_log " Source Project: #{project.id} - #{project.name} (#{project.identifier})"
|
370
|
+
debug_log " Tasks:"
|
371
|
+
project.tasks.each do |task|
|
372
|
+
debug_log " Task: #{task.id} - #{task.name}"
|
373
|
+
end
|
374
|
+
end
|
375
|
+
|
376
|
+
target_filters = @filters.fetch(:target, {}).merge(active: "true")
|
377
|
+
# Get the proxy, then fetch all results into the instance variable
|
378
|
+
@target_projects = @target.projects.where(target_filters).all
|
379
|
+
debug_log "Found #{@target_projects.size} target projects:"
|
380
|
+
@target_projects.each do |project|
|
381
|
+
debug_log " Target Project: #{project.id} - #{project.name} (#{project.identifier})"
|
382
|
+
debug_log " Tasks:"
|
383
|
+
project.tasks.each do |task|
|
384
|
+
debug_log " Task: #{task.id} - #{task.name}"
|
385
|
+
end
|
386
|
+
end
|
387
|
+
|
388
|
+
# NOTE: The @source_projects and @target_projects are now Arrays of entities,
|
389
|
+
# not CollectionProxy or EntityCollection objects.
|
174
390
|
end
|
175
391
|
|
176
392
|
def build_initial_mappings
|
@@ -179,11 +395,29 @@ module MOCO
|
|
179
395
|
next unless source_project
|
180
396
|
|
181
397
|
@project_mapping[source_project.id] = target_project
|
398
|
+
debug_log "Mapped source project #{source_project.id} (#{source_project.name}) to target project #{target_project.id} (#{target_project.name})"
|
399
|
+
|
182
400
|
target_project.tasks.each do |target_task|
|
183
401
|
source_task = match_task(target_task, source_project)
|
184
|
-
|
402
|
+
if source_task
|
403
|
+
@task_mapping[source_task.id] = target_task
|
404
|
+
debug_log " Mapped source task #{source_task.id} (#{source_task.name}) to target task #{target_task.id} (#{target_task.name})"
|
405
|
+
else
|
406
|
+
debug_log " No matching source task found for target task #{target_task.id} (#{target_task.name})"
|
407
|
+
end
|
185
408
|
end
|
186
409
|
end
|
410
|
+
|
411
|
+
# Log the final mappings
|
412
|
+
debug_log "Final project mappings:"
|
413
|
+
@project_mapping.each do |source_id, target_project|
|
414
|
+
debug_log " Source project #{source_id} -> Target project #{target_project.id} (#{target_project.name})"
|
415
|
+
end
|
416
|
+
|
417
|
+
debug_log "Final task mappings:"
|
418
|
+
@task_mapping.each do |source_id, target_task|
|
419
|
+
debug_log " Source task #{source_id} -> Target task #{target_task.id} (#{target_task.name})"
|
420
|
+
end
|
187
421
|
end
|
188
422
|
|
189
423
|
def match_project(target_project)
|
@@ -192,7 +426,7 @@ module MOCO
|
|
192
426
|
|
193
427
|
# Manually iterate since we can't rely on Enumerable methods
|
194
428
|
@source_projects.each do |project|
|
195
|
-
|
429
|
+
debug_log "Checking source project: #{project.inspect}" if @debug
|
196
430
|
searchable_projects << { original: project, name: project.name }
|
197
431
|
end
|
198
432
|
|
data/lib/moco/version.rb
CHANGED
data/lib/moco.rb
CHANGED
@@ -6,6 +6,11 @@ require "active_support/inflector"
|
|
6
6
|
|
7
7
|
require_relative "moco/version"
|
8
8
|
|
9
|
+
# Core classes needed by entities
|
10
|
+
require_relative "moco/connection"
|
11
|
+
require_relative "moco/collection_proxy"
|
12
|
+
require_relative "moco/nested_collection_proxy"
|
13
|
+
|
9
14
|
# New API (v2)
|
10
15
|
require_relative "moco/entities/base_entity"
|
11
16
|
require_relative "moco/entities/project"
|
@@ -22,9 +27,6 @@ require_relative "moco/entities/presence"
|
|
22
27
|
require_relative "moco/entities/holiday"
|
23
28
|
require_relative "moco/entities/planning_entry"
|
24
29
|
require_relative "moco/client"
|
25
|
-
require_relative "moco/connection"
|
26
|
-
require_relative "moco/collection_proxy"
|
27
|
-
require_relative "moco/nested_collection_proxy"
|
28
30
|
require_relative "moco/entity_collection"
|
29
31
|
|
30
32
|
require_relative "moco/sync"
|
data/moco.gemspec
ADDED
@@ -0,0 +1,36 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "lib/moco/version"
|
4
|
+
|
5
|
+
Gem::Specification.new do |spec|
|
6
|
+
spec.name = "moco-ruby"
|
7
|
+
spec.version = MOCO::VERSION
|
8
|
+
spec.authors = ["Teal Bauer"]
|
9
|
+
spec.email = ["rubygems@teal.is"]
|
10
|
+
|
11
|
+
spec.summary = "A Ruby Gem to interact with the MOCO (mocoapp.com) API."
|
12
|
+
spec.homepage = "https://github.com/starsong-consulting/moco-ruby"
|
13
|
+
spec.required_ruby_version = ">= 3.2.0"
|
14
|
+
spec.license = "Apache-2.0"
|
15
|
+
|
16
|
+
spec.metadata["homepage_uri"] = spec.homepage
|
17
|
+
spec.metadata["source_code_uri"] = "https://github.com/starsong-consulting/moco-ruby"
|
18
|
+
spec.metadata["changelog_uri"] = "https://github.com/starsong-consulting/moco-ruby/blob/main/CHANGELOG.md"
|
19
|
+
|
20
|
+
# Specify which files should be added to the gem when it is released.
|
21
|
+
# The `git ls-files -z` loads the files in the RubyGem that have been added into git.
|
22
|
+
spec.files = Dir.chdir(__dir__) do
|
23
|
+
`git ls-files -z`.split("\x0").reject do |f|
|
24
|
+
(f == __FILE__) || f.match(%r{\A(?:(?:bin|test|spec|features)/|\.(?:git|circleci)|appveyor)})
|
25
|
+
end
|
26
|
+
end
|
27
|
+
spec.bindir = "exe"
|
28
|
+
spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
|
29
|
+
spec.require_paths = ["lib"]
|
30
|
+
|
31
|
+
spec.add_dependency "activesupport", "~> 7.0"
|
32
|
+
spec.add_dependency "faraday", "~> 2.9.0"
|
33
|
+
spec.add_dependency "fuzzy_match", "~> 2.1.0"
|
34
|
+
|
35
|
+
spec.metadata["rubygems_mfa_required"] = "true"
|
36
|
+
end
|
data/sync_activity.rb
CHANGED
@@ -10,7 +10,8 @@ options = {
|
|
10
10
|
to: nil,
|
11
11
|
project: nil,
|
12
12
|
match_project_threshold: 0.8,
|
13
|
-
match_task_threshold: 0.45
|
13
|
+
match_task_threshold: 0.45,
|
14
|
+
debug: false
|
14
15
|
}
|
15
16
|
|
16
17
|
OptionParser.new do |opts|
|
@@ -47,6 +48,10 @@ OptionParser.new do |opts|
|
|
47
48
|
opts.on("--match-task-threshold VALUE", Float, "Task matching threshold (0.0 - 1.0), default 0.45") do |val|
|
48
49
|
options[:match_task_threshold] = val
|
49
50
|
end
|
51
|
+
|
52
|
+
opts.on("-d", "--debug", "Enable debug output") do
|
53
|
+
options[:debug] = true
|
54
|
+
end
|
50
55
|
end.parse!
|
51
56
|
|
52
57
|
source_instance = ARGV.shift
|
@@ -64,8 +69,8 @@ config = YAML.load_file("config.yml")
|
|
64
69
|
source_config = config["instances"].fetch(source_instance, nil)
|
65
70
|
target_config = config["instances"].fetch(target_instance, nil)
|
66
71
|
|
67
|
-
source_client = MOCO::Client.new(subdomain: source_instance, api_key: source_config["api_key"])
|
68
|
-
target_client = MOCO::Client.new(subdomain: target_instance, api_key: target_config["api_key"])
|
72
|
+
source_client = MOCO::Client.new(subdomain: source_instance, api_key: source_config["api_key"], debug: options[:debug])
|
73
|
+
target_client = MOCO::Client.new(subdomain: target_instance, api_key: target_config["api_key"], debug: options[:debug])
|
69
74
|
|
70
75
|
syncer = MOCO::Sync.new(
|
71
76
|
source_client,
|
@@ -76,7 +81,8 @@ syncer = MOCO::Sync.new(
|
|
76
81
|
source: options.slice(:from, :to, :project_id, :company_id, :term),
|
77
82
|
target: options.slice(:from, :to)
|
78
83
|
},
|
79
|
-
dry_run: options[:dry_run]
|
84
|
+
dry_run: options[:dry_run],
|
85
|
+
debug: options[:debug]
|
80
86
|
)
|
81
87
|
|
82
88
|
syncer.source_projects.each do |project|
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: moco-ruby
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 1.0.0
|
4
|
+
version: 1.0.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Teal Bauer
|
8
8
|
autorequire:
|
9
9
|
bindir: exe
|
10
10
|
cert_chain: []
|
11
|
-
date: 2025-
|
11
|
+
date: 2025-10-08 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: activesupport
|
@@ -94,6 +94,7 @@ files:
|
|
94
94
|
- lib/moco/nested_collection_proxy.rb
|
95
95
|
- lib/moco/sync.rb
|
96
96
|
- lib/moco/version.rb
|
97
|
+
- moco.gemspec
|
97
98
|
- mocurl.rb
|
98
99
|
- sync_activity.rb
|
99
100
|
homepage: https://github.com/starsong-consulting/moco-ruby
|
@@ -115,9 +116,9 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
115
116
|
version: 3.2.0
|
116
117
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
117
118
|
requirements:
|
118
|
-
- - "
|
119
|
+
- - ">="
|
119
120
|
- !ruby/object:Gem::Version
|
120
|
-
version:
|
121
|
+
version: '0'
|
121
122
|
requirements: []
|
122
123
|
rubygems_version: 3.4.1
|
123
124
|
signing_key:
|