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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +43 -5
- data/Gemfile +1 -0
- data/Gemfile.lock +6 -4
- data/README.md +38 -2
- data/copy_project.rb +337 -0
- 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 +31 -14
- 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 +411 -71
- data/lib/moco/version.rb +1 -1
- data/lib/moco-ruby.rb +6 -0
- data/lib/moco.rb +5 -3
- data/sync_activity.rb +16 -4
- metadata +12 -10
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
|
-
|
|
32
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
93
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
121
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
550
|
+
# Only proceed if we have tasks to match against
|
|
551
|
+
return nil if tasks.empty?
|
|
209
552
|
|
|
210
|
-
#
|
|
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
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"
|