moco-ruby 1.0.0.alpha → 1.1.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.
data/lib/moco/sync.rb CHANGED
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "set"
3
4
  require "fuzzy_match"
4
5
  require_relative "client"
5
6
 
@@ -7,7 +8,7 @@ module MOCO
7
8
  # Match and map projects and tasks between MOCO instances and sync activities
8
9
  class Sync
9
10
  attr_reader :project_mapping, :task_mapping, :source_projects, :target_projects
10
- attr_accessor :project_match_threshold, :task_match_threshold, :dry_run
11
+ attr_accessor :project_match_threshold, :task_match_threshold, :dry_run, :debug
11
12
 
12
13
  def initialize(source_client, target_client, **args)
13
14
  @source = source_client
@@ -16,89 +17,175 @@ module MOCO
16
17
  @task_match_threshold = args.fetch(:task_match_threshold, 0.45)
17
18
  @filters = args.fetch(:filters, {})
18
19
  @dry_run = args.fetch(:dry_run, false)
20
+ @debug = args.fetch(:debug, false)
21
+ @default_task_name = args.fetch(:default_task_name, nil)
19
22
 
20
23
  @project_mapping = {}
21
24
  @task_mapping = {}
25
+ @default_task_cache = {} # Cache default tasks per project
22
26
 
23
27
  fetch_assigned_projects
24
28
  build_initial_mappings
29
+ create_missing_tasks_for_activities
25
30
  end
26
31
 
27
32
  # rubocop:todo Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
28
33
  def sync(&callbacks)
29
34
  results = []
30
35
 
31
- source_activities_r = @source.activities.all(@filters.fetch(:source, {}))
32
- target_activities_r = @target.activities.all(@filters.fetch(:target, {}))
36
+ source_activity_filters = @filters.fetch(:source, {})
37
+ source_activities_r = @source.activities.where(source_activity_filters).all
38
+ debug_log "Fetched #{source_activities_r.size} source activities"
39
+
40
+ # Log source activities for debugging
41
+ debug_log "Source activities:"
42
+ source_activities_r.each do |activity|
43
+ 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}"
44
+
45
+ # Also log the expected target activity for each source activity
46
+ begin
47
+ expected = get_expected_target_activity(activity)
48
+ if expected
49
+ project_id = expected.project&.id rescue "N/A"
50
+ task_id = expected.task&.id rescue "N/A"
51
+ remote_id = expected.instance_variable_get(:@attributes)[:remote_id] rescue "N/A"
52
+ debug_log " Expected Target: Project: #{project_id}, Task: #{task_id}, Remote ID: #{remote_id}"
53
+ end
54
+ rescue => e
55
+ debug_log " Error getting expected target: #{e.message}"
56
+ end
57
+ end
58
+
59
+ target_activity_filters = @filters.fetch(:target, {})
60
+ target_activities_r = @target.activities.where(target_activity_filters).all
61
+ debug_log "Fetched #{target_activities_r.size} target activities"
62
+
63
+ # Log target activities for debugging
64
+ debug_log "Target activities:"
65
+ target_activities_r.each do |activity|
66
+ 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}"
67
+ end
33
68
 
69
+ # Group activities by date and then by project_id for consistent lookups
34
70
  source_activities_grouped = source_activities_r.group_by(&:date).transform_values do |activities|
35
- activities.group_by(&:project)
71
+ activities.group_by { |a| a.project&.id } # Group by project ID
36
72
  end
37
73
  target_activities_grouped = target_activities_r.group_by(&:date).transform_values do |activities|
38
- activities.group_by(&:project)
74
+ activities.group_by { |a| a.project&.id } # Group by project ID
39
75
  end
40
76
 
41
77
  used_source_activities = []
42
78
  used_target_activities = []
43
79
 
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?
80
+ debug_log "Starting main sync loop..."
81
+ source_activities_grouped.each do |date, activities_by_project_id|
82
+ debug_log "Processing date: #{date}"
83
+ activities_by_project_id.each do |source_project_id, source_activities|
84
+ debug_log " Processing source project ID: #{source_project_id} (#{source_activities.count} activities)"
85
+ # Find the corresponding target project ID using the mapping
86
+ target_project_object = @project_mapping[source_project_id]
87
+ unless target_project_object
88
+ debug_log " Skipping - Source project ID #{source_project_id} not mapped."
89
+ next
90
+ end
91
+
92
+ target_project_id = target_project_object.id
93
+ # Fetch target activities using the target project ID
94
+ target_activities = target_activities_grouped.fetch(date, {}).fetch(target_project_id, [])
95
+ debug_log " Found #{target_activities.count} target activities for target project ID: #{target_project_id}"
96
+
97
+ if source_activities.empty? || target_activities.empty?
98
+ debug_log " Skipping - No source or target activities for this date/project pair."
99
+ next
100
+ end
48
101
 
49
102
  matches = calculate_matches(source_activities, target_activities)
103
+ debug_log " Calculated #{matches.count} potential matches."
50
104
  matches.sort_by! { |match| -match[:score] }
51
105
 
106
+ debug_log " Entering matches loop..."
52
107
  matches.each do |match|
53
108
  source_activity, target_activity = match[:activity]
54
109
  score = match[:score]
110
+ debug_log " Match Pair: Score=#{score}, Source=#{source_activity.id}, Target=#{target_activity.id}"
55
111
 
56
- next if used_source_activities.include?(source_activity) || used_target_activities.include?(target_activity)
112
+ if used_source_activities.include?(source_activity) || used_target_activities.include?(target_activity)
113
+ debug_log " Skipping match pair - already used: Source used=#{used_source_activities.include?(source_activity)}, Target used=#{used_target_activities.include?(target_activity)}"
114
+ next
115
+ end
57
116
 
58
- best_score = score
117
+ best_score = score # Since we sorted, this is the best score for this unused pair
59
118
  best_match = target_activity
60
119
  expected_target_activity = get_expected_target_activity(source_activity)
120
+ debug_log " Processing best score #{best_score} for Source=#{source_activity.id}"
61
121
 
62
122
  case best_score
63
123
  when 100
124
+ debug_log " Case 100: Equal"
64
125
  # 100 - perfect match found, nothing needs doing
65
126
  callbacks&.call(:equal, source_activity, expected_target_activity)
127
+ # Mark both as used
128
+ debug_log " Marking Source=#{source_activity.id} and Target=#{target_activity.id} as used."
129
+ used_source_activities << source_activity
130
+ used_target_activities << target_activity
66
131
  when 60...100
132
+ debug_log " Case 60-99: Update"
67
133
  # >=60 <100 - match with some differences
68
134
  expected_target_activity.to_h.except(:id, :user, :customer).each do |k, v|
135
+ debug_log " Updating attribute #{k} on Target=#{target_activity.id}"
69
136
  best_match.send("#{k}=", v)
70
137
  end
71
138
  callbacks&.call(:update, source_activity, best_match)
72
139
  unless @dry_run
73
- results << @target.activities.update(best_match)
140
+ debug_log " Executing API update for Target=#{target_activity.id}"
141
+ results << @target.activities.update(best_match.id, best_match.attributes) # Pass ID and attributes
74
142
  callbacks&.call(:updated, source_activity, best_match, results.last)
75
143
  end
144
+ # Mark both as used
145
+ debug_log " Marking Source=#{source_activity.id} and Target=#{target_activity.id} as used."
146
+ used_source_activities << source_activity
147
+ used_target_activities << target_activity
76
148
  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
149
+ debug_log " Case 0-59: Low score, doing nothing for this pair."
150
+ # <60 - Low score for this specific pair. Do nothing here.
151
+ # Creation is handled later if source_activity remains unused.
152
+ nil # Explicitly do nothing
83
153
  end
84
-
85
- used_source_activities << source_activity
86
- used_target_activities << target_activity
154
+ # Only mark activities as used if score >= 60 (handled within the case branches above)
87
155
  end
156
+ debug_log " Finished matches loop."
88
157
  end
158
+ debug_log " Finished processing project IDs for date #{date}."
89
159
  end
160
+ debug_log "Finished main sync loop."
90
161
 
162
+ # Second loop: Create source activities that were never used (i.e., had no match >= 60)
163
+ debug_log "Starting creation loop..."
91
164
  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]
165
+ if used_source_activities.include?(source_activity)
166
+ debug_log " Skipping creation for Source=#{source_activity.id} - already used."
167
+ next
168
+ end
169
+ # Use safe navigation in case project is nil
170
+ source_project_id = source_activity.project&.id
171
+ unless @project_mapping[source_project_id]
172
+ debug_log " Skipping creation for Source=#{source_activity.id} - project #{source_project_id} not mapped."
173
+ next
174
+ end
94
175
 
176
+ debug_log " Processing creation for Source=#{source_activity.id}"
95
177
  expected_target_activity = get_expected_target_activity(source_activity)
96
178
  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
179
+ next if @dry_run
180
+
181
+ debug_log " Executing API create."
182
+ # Pass attributes hash to create
183
+ created_activity = @target.activities.create(expected_target_activity.attributes)
184
+ results << created_activity
185
+ # Pass the actual created activity object to the callback
186
+ callbacks&.call(:created, source_activity, created_activity, results.last)
101
187
  end
188
+ debug_log "Finished creation loop."
102
189
 
103
190
  results
104
191
  end
@@ -106,21 +193,54 @@ module MOCO
106
193
 
107
194
  private
108
195
 
196
+ def debug_log(message)
197
+ warn "[SYNC DEBUG] #{message}" if @debug
198
+ end
199
+
109
200
  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
201
+ # Create a duplicate of the source activity
202
+ new_activity = source_activity.dup
203
+
204
+ # Get the attributes hash
205
+ attrs = new_activity.instance_variable_get(:@attributes)
206
+
207
+ # Store the mapped task and project objects for reference
208
+ mapped_task = @task_mapping[source_activity.task&.id]
209
+ mapped_project = @project_mapping[source_activity.project&.id]
210
+
211
+ # Set the task_id and project_id attributes instead of the full objects
212
+ attrs[:task_id] = mapped_task.id if mapped_task
213
+ attrs[:project_id] = mapped_project.id if mapped_project
214
+
215
+ # Set remote_id to the source activity ID for future matching
216
+ attrs[:remote_id] = source_activity.id.to_s
217
+
218
+ # Remove the full objects from the attributes hash
219
+ attrs.delete(:task)
220
+ attrs.delete(:project)
221
+
222
+ # Return the modified activity
223
+ new_activity
114
224
  end
115
225
 
116
226
  def calculate_matches(source_activities, target_activities)
117
227
  matches = []
228
+
118
229
  source_activities.each do |source_activity|
119
230
  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: }
231
+ # First check if this is a previously synced activity by comparing IDs directly
232
+ if target_activity.respond_to?(:remote_id) &&
233
+ target_activity.remote_id.to_s == source_activity.id.to_s
234
+ debug_log "Direct match found: target.remote_id=#{target_activity.remote_id} matches source.id=#{source_activity.id}" if @debug
235
+ matches << { activity: [source_activity, target_activity], score: 100 }
236
+ else
237
+ # If no direct match, use the regular scoring method
238
+ score = score_activity_match(get_expected_target_activity(source_activity), target_activity)
239
+ matches << { activity: [source_activity, target_activity], score: }
240
+ end
122
241
  end
123
242
  end
243
+
124
244
  matches
125
245
  end
126
246
 
@@ -132,45 +252,145 @@ module MOCO
132
252
  [0.0, score].max
133
253
  end
134
254
 
135
- # rubocop:disable Metrics/AbcSize
255
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
136
256
  def score_activity_match(a, b)
257
+ # Must be same project
137
258
  return 0 if a.project != b.project
138
259
 
260
+ # Check for exact ID match (for activities that were previously synced)
261
+ # This is the most important check and overrides all others
262
+ if a.id.to_s == b.remote_id.to_s || b.id.to_s == a.remote_id.to_s
263
+ debug_log "Found exact ID match between #{a.id} and #{b.id}" if @debug
264
+ return 100
265
+ end
266
+
267
+ # Check for exact ID match in remote_id field
268
+ if a.remote_id.to_s == b.id.to_s || b.remote_id.to_s == a.id.to_s
269
+ debug_log "Found exact ID match in remote_id: a.remote_id=#{a.remote_id}, b.id=#{b.id}" if @debug
270
+ return 100
271
+ end
272
+
273
+ # Additional check for remote_id in attributes hash
274
+ begin
275
+ a_remote_id = a.instance_variable_get(:@attributes)[:remote_id].to_s rescue nil
276
+ b_remote_id = b.instance_variable_get(:@attributes)[:remote_id].to_s rescue nil
277
+
278
+ if (a_remote_id && !a_remote_id.empty? && a_remote_id == b.id.to_s) ||
279
+ (b_remote_id && !b_remote_id.empty? && b_remote_id == a.id.to_s)
280
+ debug_log "Found exact ID match in attributes hash: a.attributes[:remote_id]=#{a_remote_id}, b.id=#{b.id}" if @debug
281
+ return 100
282
+ end
283
+ rescue => e
284
+ debug_log "Error checking remote_id in attributes: #{e.message}" if @debug
285
+ end
286
+
287
+ # Date comparison - must be same date
288
+ # Convert to string for comparison to handle different date object types
289
+ # and normalize format to YYYY-MM-DD
290
+ debug_log "Raw dates: a.date=#{a.date.inspect} (#{a.date.class}), b.date=#{b.date.inspect} (#{b.date.class})" if @debug
291
+
292
+ # Normalize dates to YYYY-MM-DD format
293
+ a_date = normalize_date(a.date)
294
+ b_date = normalize_date(b.date)
295
+
296
+ debug_log "Normalized dates: a_date=#{a_date}, b_date=#{b_date}" if @debug
297
+
298
+ if a_date != b_date
299
+ debug_log "Date mismatch: #{a_date} vs #{b_date}" if @debug
300
+ return 0
301
+ end
302
+
139
303
  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
304
+
305
+ # Task matching is important (30 points)
306
+ if a.task&.id == b.task&.id
307
+ score += 30
308
+ debug_log "Task match: +30 points" if @debug
309
+ end
310
+
311
+ # Description matching (up to 30 points)
312
+ if a.description.to_s.strip.empty? && b.description.to_s.strip.empty?
313
+ # Both empty descriptions - consider it a match for this attribute
314
+ score += 30
315
+ debug_log "Empty description match: +30 points" if @debug
316
+ else
317
+ # Use fuzzy matching for non-empty descriptions
318
+ _, description_match_score = FuzzyMatch.new([a.description.to_s]).find_with_score(b.description.to_s)
319
+ if description_match_score
320
+ desc_points = (description_match_score * 30.0).to_i
321
+ score += desc_points
322
+ debug_log "Description match (#{description_match_score}): +#{desc_points} points" if @debug
323
+ end
324
+ end
325
+
326
+ # Hours matching (up to 40 points)
327
+ # Exact hour match gets full points
328
+ if a.hours == b.hours
329
+ score += 40
330
+ debug_log "Exact hours match: +40 points" if @debug
331
+ else
332
+ # Otherwise use the clamped difference score
333
+ hours_points = (clamped_factored_diff_score(a.hours, b.hours) * 40.0).to_i
334
+ score += hours_points
335
+ debug_log "Hours similarity (#{a.hours} vs #{b.hours}): +#{hours_points} points" if @debug
336
+ end
337
+
338
+ debug_log "Final score for #{a.id} vs #{b.id}: #{score}" if @debug
148
339
 
149
340
  score
150
341
  end
151
- # rubocop:enable Metrics/AbcSize
342
+
343
+ # Helper method to normalize dates to YYYY-MM-DD format
344
+ def normalize_date(date_value)
345
+ return nil if date_value.nil?
346
+
347
+ date_str = date_value.to_s
348
+
349
+ # First try to extract YYYY-MM-DD from ISO format
350
+ date_str = date_str.split("T").first.strip if date_str.include?("T")
351
+
352
+ # Handle different date formats
353
+ begin
354
+ # Try to parse as Date object if it's not already in YYYY-MM-DD format
355
+ date_str = Date.parse(date_str).strftime("%Y-%m-%d") unless date_str =~ /^\d{4}-\d{2}-\d{2}$/
356
+ rescue StandardError => e
357
+ debug_log "Error normalizing date '#{date_str}': #{e.message}" if @debug
358
+ # If parsing fails, return the original string
359
+ end
360
+
361
+ date_str
362
+ end
363
+
364
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
152
365
 
153
366
  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
367
+ # Use .projects.assigned for the source, standard .projects for the target
368
+ source_filters = @filters.fetch(:source, {}).merge(active: "true")
369
+ # Get the proxy, then fetch all results into the instance variable
370
+ @source_projects = @source.projects.assigned.where(source_filters).all
371
+ debug_log "Found #{@source_projects.size} source projects:"
372
+ @source_projects.each do |project|
373
+ debug_log " Source Project: #{project.id} - #{project.name} (#{project.identifier})"
374
+ debug_log " Tasks:"
375
+ project.tasks.each do |task|
376
+ debug_log " Task: #{task.id} - #{task.name}"
377
+ end
378
+ end
379
+
380
+ target_filters = @filters.fetch(:target, {}).merge(active: "true")
381
+ # Get the proxy, then fetch all results into the instance variable
382
+ @target_projects = @target.projects.where(target_filters).all
383
+ debug_log "Found #{@target_projects.size} target projects:"
384
+ @target_projects.each do |project|
385
+ debug_log " Target Project: #{project.id} - #{project.name} (#{project.identifier})"
386
+ debug_log " Tasks:"
387
+ project.tasks.each do |task|
388
+ debug_log " Task: #{task.id} - #{task.name}"
389
+ end
390
+ end
391
+
392
+ # NOTE: The @source_projects and @target_projects are now Arrays of entities,
393
+ # not CollectionProxy or EntityCollection objects.
174
394
  end
175
395
 
176
396
  def build_initial_mappings
@@ -179,20 +399,142 @@ module MOCO
179
399
  next unless source_project
180
400
 
181
401
  @project_mapping[source_project.id] = target_project
402
+ debug_log "Mapped source project #{source_project.id} (#{source_project.name}) to target project #{target_project.id} (#{target_project.name})"
403
+
182
404
  target_project.tasks.each do |target_task|
183
405
  source_task = match_task(target_task, source_project)
184
- @task_mapping[source_task.id] = target_task if source_task
406
+ if source_task
407
+ @task_mapping[source_task.id] = target_task
408
+ debug_log " Mapped source task #{source_task.id} (#{source_task.name}) to target task #{target_task.id} (#{target_task.name})"
409
+ else
410
+ debug_log " No matching source task found for target task #{target_task.id} (#{target_task.name})"
411
+ end
412
+ end
413
+ end
414
+
415
+ # Log the final mappings
416
+ debug_log "Final project mappings:"
417
+ @project_mapping.each do |source_id, target_project|
418
+ debug_log " Source project #{source_id} -> Target project #{target_project.id} (#{target_project.name})"
419
+ end
420
+
421
+ debug_log "Final task mappings:"
422
+ @task_mapping.each do |source_id, target_task|
423
+ debug_log " Source task #{source_id} -> Target task #{target_task.id} (#{target_task.name})"
424
+ end
425
+ end
426
+
427
+ def create_missing_tasks_for_activities
428
+ # Fetch source activities to see which tasks are actually used
429
+ source_activity_filters = @filters.fetch(:source, {})
430
+ source_activities = @source.activities.where(source_activity_filters).all
431
+
432
+ # Collect unique task IDs that are used in activities and need syncing
433
+ tasks_needed = Set.new
434
+ source_activities.each do |activity|
435
+ # Only consider activities for mapped projects
436
+ next unless @project_mapping[activity.project&.id]
437
+ # Check if task is already mapped
438
+ next if activity.task.nil?
439
+ next if @task_mapping[activity.task.id]
440
+
441
+ tasks_needed.add(activity.task.id)
442
+ end
443
+
444
+ return if tasks_needed.empty?
445
+
446
+ debug_log "Found #{tasks_needed.size} unmapped tasks used in activities"
447
+
448
+ # Track tasks that couldn't be created due to permission errors
449
+ @failed_task_creations ||= []
450
+ @mapped_to_default ||= []
451
+
452
+ # Create missing tasks in target projects
453
+ tasks_needed.each do |task_id|
454
+ # Find the source task from source activities
455
+ source_activity = source_activities.find { |a| a.task&.id == task_id }
456
+ next unless source_activity
457
+
458
+ source_task = source_activity.task
459
+ source_project_id = source_activity.project.id
460
+ target_project = @project_mapping[source_project_id]
461
+
462
+ # If default task name is provided, try to map to it instead of creating
463
+ if @default_task_name
464
+ default_task = find_default_task(target_project)
465
+ if default_task
466
+ @task_mapping[source_task.id] = default_task
467
+ debug_log " Mapped task '#{source_task.name}' -> default task '#{default_task.name}' (#{default_task.id})"
468
+ @mapped_to_default << {
469
+ task_name: source_task.name,
470
+ project_name: target_project.name,
471
+ default_task_name: default_task.name
472
+ }
473
+ next
474
+ else
475
+ warn " WARNING: Default task '#{@default_task_name}' not found in target project '#{target_project.name}'"
476
+ warn " Will attempt to create task '#{source_task.name}' instead"
477
+ end
478
+ end
479
+
480
+ debug_log " Creating missing task '#{source_task.name}' in target project #{target_project.id} (#{target_project.name})"
481
+
482
+ unless @dry_run
483
+ begin
484
+ # Create the task in the target project
485
+ # Tasks used in activities must be active
486
+ # Use NestedCollectionProxy to create the task
487
+ task_proxy = MOCO::NestedCollectionProxy.new(@target, target_project, :tasks, "Task")
488
+ new_task = task_proxy.create(
489
+ name: source_task.name,
490
+ billable: source_task.billable,
491
+ active: true
492
+ )
493
+
494
+ # Add to mapping
495
+ @task_mapping[source_task.id] = new_task
496
+ debug_log " Created task #{new_task.id} - #{new_task.name}"
497
+ rescue StandardError => e
498
+ # Check if this is a permission error
499
+ if e.message =~ /403|Forbidden|401|Unauthorized|not authorized|permission/i
500
+ warn " WARNING: Cannot create task '#{source_task.name}' in target project - insufficient permissions"
501
+ warn " Activities using this task will be skipped during sync"
502
+ @failed_task_creations << {
503
+ task_name: source_task.name,
504
+ project_name: target_project.name,
505
+ project_id: target_project.id
506
+ }
507
+ else
508
+ # Re-raise other errors
509
+ raise
510
+ end
511
+ end
512
+ else
513
+ debug_log " (Dry run - would create task '#{source_task.name}')"
185
514
  end
186
515
  end
187
516
  end
188
517
 
518
+ def find_default_task(target_project)
519
+ # Return cached result if available
520
+ return @default_task_cache[target_project.id] if @default_task_cache.key?(target_project.id)
521
+
522
+ # Search for the default task in the target project
523
+ default_task = target_project.tasks.find { |task| task.name == @default_task_name }
524
+
525
+ # Cache the result (even if nil)
526
+ @default_task_cache[target_project.id] = default_task
527
+
528
+ default_task
529
+ end
530
+
189
531
  def match_project(target_project)
190
532
  # Create array of search objects manually since we can't call map on EntityCollection
191
533
  searchable_projects = []
192
534
 
193
535
  # Manually iterate since we can't rely on Enumerable methods
194
536
  @source_projects.each do |project|
195
- warn project.inspect
537
+ debug_log "Checking source project: #{project.inspect}" if @debug
196
538
  searchable_projects << { original: project, name: project.name }
197
539
  end
198
540
 
@@ -202,19 +544,17 @@ module MOCO
202
544
  end
203
545
 
204
546
  def match_task(target_task, source_project)
205
- # Get tasks from the source project
547
+ # Get tasks from the source project (embedded in projects.assigned response)
206
548
  tasks = source_project.tasks
207
549
 
208
- # Create array of search objects manually since we can't rely on Enumerable methods
550
+ # Only proceed if we have tasks to match against
551
+ return nil if tasks.empty?
209
552
 
210
- # Manually iterate through tasks
553
+ # Create array of search objects for fuzzy matching
211
554
  searchable_tasks = tasks.map do |task|
212
555
  { original: task, name: task.name }
213
556
  end
214
557
 
215
- # Only proceed if we have tasks to match against
216
- return nil if searchable_tasks.empty?
217
-
218
558
  matcher = FuzzyMatch.new(searchable_tasks, read: :name)
219
559
  match = matcher.find(target_task.name, threshold: @task_match_threshold)
220
560
  match[:original] if match
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.1.0"
5
5
  end
data/lib/moco-ruby.rb ADDED
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ # This file exists so that `gem 'moco-ruby'` auto-requires correctly.
4
+ # Bundler converts gem names with hyphens to require paths with slashes,
5
+ # so 'moco-ruby' looks for 'moco/ruby'. This shim redirects to the real entry point.
6
+ require_relative "moco"
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"