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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cda64cf9e66f0409e61b5fc1b0ad5b0bc440acfe3e1202d9baab08c87c21d6b1
4
- data.tar.gz: cc6e0952ee9c89a4ac73f08c0093f1723563346f8bd2cfec27901faeed943ae8
3
+ metadata.gz: 94fd15c735a242e23f7a1e20dd49408d12dbfbb21f1665ca64bfd85ba3251cae
4
+ data.tar.gz: 710f5ce6be51b2c363c6d2b3df871d755e5b21e90e9ad53cbb535ac249b46b6f
5
5
  SHA512:
6
- metadata.gz: f3bc2329dca452e3efbecc83b015532f5f32102d4e26a8bd787e036df3ac2384ac6f3ae95b0a67999cb9858197357cc0ee5beb744ad07168268e3067d6f774e7
7
- data.tar.gz: 8189ab5b3015b78d1c93c0231497283e1d1d89ef1673c02d0762f161fcb6d277e4350f052fe799b80993b14cd645db0290ee0379c9455c840abe3646afa54415
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.alpha...HEAD
93
- [1.0.0.alpha]: https://github.com/starsong-consulting/moco-ruby/compare/v1.0.0...v1.0.0.alpha
94
- [1.0.0]: https://github.com/starsong-consulting/moco-ruby/compare/v0.1.2...v1.0.0
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
@@ -8,3 +8,4 @@ gem "rake", "~> 13.0"
8
8
  gem "rubocop", "~> 1.21"
9
9
  gem "test-unit", "~> 3.5"
10
10
  gem "webmock", "~> 3.18"
11
+ gem "dotenv", "~> 2.8"
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- moco-ruby (1.0.0.alpha)
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. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
235
+ After checking out the repo, run `bin/setup` to install dependencies.
236
236
 
237
- To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, 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).
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
@@ -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, use it directly
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 case: map :customer key to Company class
277
- type_name = if key_hint == :customer
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
- @project ||= client.projects.find(project_id) if project_id
24
+ # Use the association method which handles embedded objects
25
+ association(:project, "Project")
19
26
  end
20
27
 
21
28
  def user
22
- @user ||= client.users.find(user_id) if user_id
29
+ # Use the association method which handles embedded objects
30
+ association(:user, "User")
23
31
  end
24
32
 
25
33
  def to_s
@@ -5,7 +5,7 @@ module MOCO
5
5
  # Provides methods for holiday-specific associations
6
6
  class Holiday < BaseEntity
7
7
  # Override entity_path to match API path
8
- def entity_path
8
+ def self.entity_path
9
9
  "users/holidays"
10
10
  end
11
11
 
@@ -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
- # Check if tasks are already loaded in attributes
19
- if attributes[:tasks].is_a?(Array) && attributes[:tasks].all? { |t| t.is_a?(MOCO::Task) }
20
- # If tasks are already loaded, create a NestedCollectionProxy with the loaded tasks
21
- @_tasks_proxy ||= begin
22
- require_relative "../nested_collection_proxy"
23
- proxy = MOCO::NestedCollectionProxy.new(client, self, :tasks, "Task")
24
- # We need to manually set the loaded records since we already have them
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?
@@ -5,7 +5,7 @@ module MOCO
5
5
  # Provides methods for webhook-specific operations
6
6
  class WebHook < BaseEntity
7
7
  # Override entity_path to match API path
8
- def entity_path
8
+ def self.entity_path
9
9
  "account/web_hooks"
10
10
  end
11
11
 
@@ -14,8 +14,8 @@ module MOCO
14
14
  @entity_class_name = entity_class_name
15
15
  end
16
16
 
17
- def all(params = {})
18
- collection.all(params)
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
- all.each(&)
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
- "#{parent_type.pluralize}/#{parent.id}/#{super}"
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
- source_activities_r = @source.activities.all(@filters.fetch(:source, {}))
32
- target_activities_r = @target.activities.all(@filters.fetch(:target, {}))
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(&:project)
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(&:project)
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
- source_activities_grouped.each do |date, activities_by_project|
45
- activities_by_project.each do |project, source_activities|
46
- target_activities = target_activities_grouped.fetch(date, {}).fetch(@project_mapping[project.id], [])
47
- next if source_activities.empty? || target_activities.empty?
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
- next if used_source_activities.include?(source_activity) || used_target_activities.include?(target_activity)
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
- results << @target.activities.update(best_match)
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
- # <60 - no good match found, create new entry
78
- callbacks&.call(:create, source_activity, expected_target_activity)
79
- unless @dry_run
80
- results << @target.activities.create(expected_target_activity)
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
- next if used_source_activities.include?(source_activity)
93
- next unless @project_mapping[source_activity.project.id]
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
- unless @dry_run
98
- results << @target.activities.create(expected_target_activity)
99
- callbacks&.call(:created, source_activity, expected_target_activity, results.last)
100
- end
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
- source_activity.dup.tap do |a|
111
- a.task = @task_mapping[source_activity.task.id]
112
- a.project = @project_mapping[source_activity.project.id]
113
- end
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
- score = score_activity_match(get_expected_target_activity(source_activity), target_activity)
121
- matches << { activity: [source_activity, target_activity], score: }
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
- # (mapped) task is the same as the source task
141
- score += 20 if a.task == b.task
142
- # description fuzzy match score (0.0 .. 1.0)
143
- _, description_match_score = FuzzyMatch.new([a.description]).find_with_score(b.description)
144
- score += (description_match_score * 40.0).to_i if description_match_score
145
- # differences in time tracked are weighted by sqrt of diff clamped to 7h
146
- # i.e. smaller differences are worth higher scores; 1.75h diff = 0.5 score * 40
147
- score += (clamped_factored_diff_score(a.hours, b.hours) * 40.0).to_i
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
- # rubocop:enable Metrics/AbcSize
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
- @source_projects = @source.projects.all(**@filters.fetch(:source, {}), active: "true")
155
- @target_projects = @target.projects.all(**@filters.fetch(:target, {}), active: "true")
156
-
157
- # Ensure we have proper collections
158
- @source_projects = if @source_projects.is_a?(MOCO::EntityCollection)
159
- @source_projects
160
- else
161
- MOCO::EntityCollection.new(@source,
162
- "projects", "Project").tap do |c|
163
- c.instance_variable_set(:@items, [@source_projects])
164
- end
165
- end
166
- @target_projects = if @target_projects.is_a?(MOCO::EntityCollection)
167
- @target_projects
168
- else
169
- MOCO::EntityCollection.new(@target,
170
- "projects", "Project").tap do |c|
171
- c.instance_variable_set(:@items, [@target_projects])
172
- end
173
- end
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
- @task_mapping[source_task.id] = target_task if source_task
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
- warn project.inspect
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module MOCO
4
- VERSION = "1.0.0.alpha"
4
+ VERSION = "1.0.0"
5
5
  end
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.alpha
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-04-10 00:00:00.000000000 Z
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: 1.3.1
121
+ version: '0'
121
122
  requirements: []
122
123
  rubygems_version: 3.4.1
123
124
  signing_key: