moco-ruby 1.0.0 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 94fd15c735a242e23f7a1e20dd49408d12dbfbb21f1665ca64bfd85ba3251cae
4
- data.tar.gz: 710f5ce6be51b2c363c6d2b3df871d755e5b21e90e9ad53cbb535ac249b46b6f
3
+ metadata.gz: 5e60f0a59b12881fc99571967ff77dd1ae8c55ff8f3d45cd467bed9e6bf85c5d
4
+ data.tar.gz: a2070fba649482eb59f4ab96367cc5d8f12b5bfb0f6923bc7e7f6d2c69135566
5
5
  SHA512:
6
- metadata.gz: 272eede87d5a02636ca02354ea0ef592f0d21662378fb6817b68cc6fa73e66922b43623f6ac5da5c4c76ac3db9c6ebaee12a340fcc9920df93104303f96668fd
7
- data.tar.gz: 12f55e014641e51ecf2f666c04bff0a5f330ae68b91cf63e70060b6f85e5fc759eed517854060190651581a7ffba5bfa8345fdd25b8e2e59a96193a32cdece9a
6
+ metadata.gz: 867202c4431ae5a1d86fe9a8b18451c4cff6465d2119f5e9738d8a1b3018c6b81c726c00860762cf97ad233632df61bc38588ee0cf2d468f9516e36d65d3ed9f
7
+ data.tar.gz: 5a7cebee47463f888880ae2f9c0fa672e294adb2a0a1abcf3f098cfe241ac35eb84091607c035d36658cf5eb386880f3fc7f2db2abd0c5de8668c9ae72622333
data/Gemfile.lock CHANGED
@@ -2,8 +2,8 @@ PATH
2
2
  remote: .
3
3
  specs:
4
4
  moco-ruby (1.0.0)
5
- activesupport (~> 7.0)
6
- faraday (~> 2.9.0)
5
+ activesupport (>= 7.0)
6
+ faraday (>= 2.0)
7
7
  fuzzy_match (~> 2.1.0)
8
8
 
9
9
  GEM
@@ -60,7 +60,7 @@ GEM
60
60
  rainbow (3.1.1)
61
61
  rake (13.2.1)
62
62
  regexp_parser (2.10.0)
63
- rexml (3.4.1)
63
+ rexml (3.4.4)
64
64
  rubocop (1.75.2)
65
65
  json (~> 2.3)
66
66
  language_server-protocol (~> 3.17.0.2)
data/README.md CHANGED
@@ -226,10 +226,20 @@ Usage: sync_activity.rb [options] source_subdomain target_subdomain
226
226
  --match-project-threshold VALUE
227
227
  Fuzzy match threshold for projects (0.0 - 1.0), default 0.8
228
228
  --match-task-threshold VALUE Fuzzy match threshold for tasks (0.0 - 1.0), default 0.45
229
+ --default-task TASK_NAME Map unmatched tasks to this default task instead of creating new tasks
230
+ -d, --debug Enable debug output
229
231
  -h, --help Show this message
230
232
  ```
231
233
  **Example:** `sync_activity.rb --from 2024-04-01 --to 2024-04-10 --dry-run source-instance target-instance`
232
234
 
235
+ **Using Default Task Mapping:** If your target account has limited permissions and cannot create tasks, or if you want to consolidate multiple source tasks into a single target task, use the `--default-task` flag:
236
+
237
+ ```bash
238
+ sync_activity.rb --from 2024-04-01 --to 2024-04-10 --default-task "Other" source-instance target-instance
239
+ ```
240
+
241
+ This will map any unmatched source tasks to a task named "Other" in the corresponding target project, avoiding the need to create new tasks.
242
+
233
243
  ## Development
234
244
 
235
245
  After checking out the repo, run `bin/setup` to install dependencies.
data/copy_project.rb ADDED
@@ -0,0 +1,337 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "optparse"
5
+ require "yaml"
6
+ require "fuzzy_match"
7
+ require_relative "lib/moco"
8
+
9
+ options = {
10
+ dry_run: false,
11
+ verbose: false,
12
+ copy_activities: true
13
+ }
14
+
15
+ OptionParser.new do |opts|
16
+ opts.banner = "Usage: #{$PROGRAM_NAME} [options] source_instance target_instance project_identifier"
17
+
18
+ opts.on("-n", "--dry-run", "Show what would be copied without making changes") do
19
+ options[:dry_run] = true
20
+ end
21
+
22
+ opts.on("-v", "--verbose", "Enable verbose output") do
23
+ options[:verbose] = true
24
+ end
25
+
26
+ opts.on("--no-activities", "Skip copying activities (only copy project and tasks)") do
27
+ options[:copy_activities] = false
28
+ end
29
+
30
+ opts.on("-h", "--help", "Show this message") do
31
+ puts opts
32
+ exit
33
+ end
34
+ end.parse!
35
+
36
+ source_instance = ARGV.shift
37
+ target_instance = ARGV.shift
38
+ project_identifier = ARGV.shift
39
+
40
+ if source_instance.nil? || target_instance.nil? || project_identifier.nil?
41
+ warn "Error: source_instance, target_instance, and project_identifier are required"
42
+ warn "Usage: #{$PROGRAM_NAME} [options] source_instance target_instance project_identifier"
43
+ exit 1
44
+ end
45
+
46
+ # Load configuration
47
+ config = YAML.load_file("config.yml")
48
+ source_config = config["instances"].fetch(source_instance, nil)
49
+ target_config = config["instances"].fetch(target_instance, nil)
50
+
51
+ if source_config.nil?
52
+ warn "Error: Source instance '#{source_instance}' not found in config.yml"
53
+ exit 1
54
+ end
55
+
56
+ if target_config.nil?
57
+ warn "Error: Target instance '#{target_instance}' not found in config.yml"
58
+ exit 1
59
+ end
60
+
61
+ # Initialize clients
62
+ puts "Connecting to instances..."
63
+ source_client = MOCO::Client.new(subdomain: source_instance, api_key: source_config["api_key"])
64
+ target_client = MOCO::Client.new(subdomain: target_instance, api_key: target_config["api_key"])
65
+
66
+ def log(message, verbose: false, options:)
67
+ return if verbose && !options[:verbose]
68
+ puts message
69
+ end
70
+
71
+ def get_id(obj)
72
+ return nil if obj.nil?
73
+ return obj[:id] if obj.is_a?(Hash)
74
+ return obj.id if obj.respond_to?(:id)
75
+ nil
76
+ end
77
+
78
+ def find_or_create_customer(source_customer, target_client, options)
79
+ log("Looking for customer '#{source_customer.name}' in target...", options: options)
80
+
81
+ # Try to find the customer by name
82
+ target_customers = target_client.companies.all
83
+ target_customer = target_customers.find { |c| c.name == source_customer.name }
84
+
85
+ if target_customer
86
+ log("✅ Found existing customer: #{target_customer.name} (ID: #{target_customer.id})", options: options)
87
+ return target_customer
88
+ end
89
+
90
+ log("Customer not found, creating new customer...", options: options)
91
+
92
+ if options[:dry_run]
93
+ log("🔍 [DRY RUN] Would create customer: #{source_customer.name}", options: options)
94
+ return nil
95
+ end
96
+
97
+ # Create the customer
98
+ customer_attrs = {
99
+ name: source_customer.name,
100
+ type: "customer"
101
+ }
102
+
103
+ # Add optional attributes if they exist
104
+ customer_attrs[:type] = source_customer.type if source_customer.respond_to?(:type) && source_customer.type
105
+ customer_attrs[:currency] = source_customer.currency if source_customer.respond_to?(:currency) && source_customer.currency
106
+ customer_attrs[:website] = source_customer.website if source_customer.respond_to?(:website) && source_customer.website
107
+ customer_attrs[:address] = source_customer.address if source_customer.respond_to?(:address) && source_customer.address
108
+ customer_attrs[:info] = source_customer.info if source_customer.respond_to?(:info) && source_customer.info
109
+ customer_attrs[:custom_properties] = source_customer.custom_properties if source_customer.respond_to?(:custom_properties) && source_customer.custom_properties
110
+ customer_attrs[:labels] = source_customer.labels if source_customer.respond_to?(:labels) && source_customer.labels
111
+
112
+ log("Creating customer: #{customer_attrs[:name]}", verbose: true, options: options)
113
+ new_customer = target_client.companies.create(customer_attrs)
114
+ log("✅ Created customer: #{new_customer.name} (ID: #{new_customer.id})", options: options)
115
+
116
+ new_customer
117
+ end
118
+
119
+ def copy_tasks(source_project, target_project, target_client, options)
120
+ log("\nCopying tasks...", options: options)
121
+ task_mapping = {}
122
+
123
+ source_tasks = source_project.tasks
124
+ log("Found #{source_tasks.count} tasks in source project", options: options)
125
+
126
+ source_tasks.each do |source_task|
127
+ log(" Task: #{source_task.name}", verbose: true, options: options)
128
+
129
+ if options[:dry_run]
130
+ log(" 🔍 [DRY RUN] Would create task: #{source_task.name}", options: options)
131
+ next
132
+ end
133
+
134
+ # Create the task in the target project
135
+ task_attrs = {
136
+ name: source_task.name,
137
+ billable: source_task.billable,
138
+ active: source_task.active
139
+ }
140
+
141
+ # Add optional attributes if they exist
142
+ source_task_attrs = source_task.instance_variable_get(:@attributes)
143
+ task_attrs[:budget] = source_task_attrs[:budget] if source_task_attrs[:budget]
144
+ task_attrs[:hourly_rate] = source_task_attrs[:hourly_rate] if source_task_attrs[:hourly_rate]
145
+
146
+ log(" Creating task: #{task_attrs[:name]}", verbose: true, options: options)
147
+ # Use the NestedCollectionProxy to create the task
148
+ task_proxy = MOCO::NestedCollectionProxy.new(target_client, target_project, :tasks, "Task")
149
+ new_task = task_proxy.create(task_attrs)
150
+ task_mapping[source_task.id] = new_task
151
+ log(" ✅ Created task: #{new_task.name} (ID: #{new_task.id})", options: options)
152
+ end
153
+
154
+ task_mapping
155
+ end
156
+
157
+ def copy_activities(source_project, target_project, task_mapping, source_client, target_client, options)
158
+ log("\nCopying activities...", options: options)
159
+
160
+ # Get all activities for the source project
161
+ source_activities = source_client.activities.where(project_id: source_project.id).all
162
+ log("Found #{source_activities.count} activities in source project", options: options)
163
+
164
+ created_count = 0
165
+ skipped_count = 0
166
+
167
+ source_activities.each do |source_activity|
168
+ log(" Activity: #{source_activity.date} - #{source_activity.hours}h - #{source_activity.description}", verbose: true, options: options)
169
+
170
+ if options[:dry_run]
171
+ log(" 🔍 [DRY RUN] Would create activity: #{source_activity.date} - #{source_activity.hours}h", options: options)
172
+ next
173
+ end
174
+
175
+ # Map the task
176
+ target_task = task_mapping[source_activity.task&.id]
177
+
178
+ if source_activity.task && !target_task
179
+ log(" ⚠️ Skipping activity - task not mapped: #{source_activity.task.name}", options: options)
180
+ skipped_count += 1
181
+ next
182
+ end
183
+
184
+ # Create the activity in the target project
185
+ activity_attrs = {
186
+ date: source_activity.date,
187
+ hours: source_activity.hours,
188
+ description: source_activity.description,
189
+ project_id: target_project.id,
190
+ billable: source_activity.billable,
191
+ tag: source_activity.tag,
192
+ remote_service: source_activity.remote_service,
193
+ remote_id: source_activity.id.to_s # Store original ID for reference
194
+ }
195
+
196
+ activity_attrs[:task_id] = target_task.id if target_task
197
+
198
+ log(" Creating activity: #{activity_attrs[:date]} - #{activity_attrs[:hours]}h", verbose: true, options: options)
199
+
200
+ begin
201
+ new_activity = target_client.activities.create(activity_attrs)
202
+ created_count += 1
203
+ log(" ✅ Created activity: #{new_activity.date} - #{new_activity.hours}h (ID: #{new_activity.id})", verbose: true, options: options)
204
+ rescue => e
205
+ log(" ❌ Error creating activity: #{e.message}", options: options)
206
+ skipped_count += 1
207
+ end
208
+ end
209
+
210
+ log("\n✅ Created #{created_count} activities", options: options)
211
+ log("⚠️ Skipped #{skipped_count} activities", options: options) if skipped_count > 0
212
+ end
213
+
214
+ # Main execution
215
+ begin
216
+ log("=" * 80, options: options)
217
+ log("MOCO Project Copy Tool", options: options)
218
+ log("=" * 80, options: options)
219
+ log("Source: #{source_instance}", options: options)
220
+ log("Target: #{target_instance}", options: options)
221
+ log("Project: #{project_identifier}", options: options)
222
+ log("Mode: #{options[:dry_run] ? 'DRY RUN' : 'LIVE'}", options: options)
223
+ log("=" * 80, options: options)
224
+
225
+ # Find the source project
226
+ log("\nFinding source project...", options: options)
227
+ source_project = source_client.projects.where(identifier: project_identifier).all.first
228
+
229
+ if source_project.nil?
230
+ warn "Error: Project '#{project_identifier}' not found in source instance"
231
+ exit 1
232
+ end
233
+
234
+ log("✅ Found project: #{source_project.name} (ID: #{source_project.id})", options: options)
235
+ log(" Identifier: #{source_project.identifier}", verbose: true, options: options)
236
+ log(" Status: #{source_project.active ? 'Active' : 'Inactive'}", verbose: true, options: options)
237
+
238
+ # Get the customer
239
+ source_customer = source_project.customer
240
+ if source_customer.nil?
241
+ warn "Error: Project has no associated customer"
242
+ exit 1
243
+ end
244
+
245
+ log(" Customer: #{source_customer.name} (ID: #{source_customer.id})", options: options)
246
+
247
+ # Find or create the customer in the target
248
+ target_customer = find_or_create_customer(source_customer, target_client, options)
249
+
250
+ if options[:dry_run]
251
+ log("\n🔍 [DRY RUN] Would create project: #{source_project.name}", options: options)
252
+ log(" Identifier: #{source_project.identifier}", options: options)
253
+ log(" Customer: #{source_customer.name}", options: options)
254
+
255
+ # Still show what tasks would be copied
256
+ copy_tasks(source_project, source_project, target_client, options)
257
+
258
+ if options[:copy_activities]
259
+ log("\n🔍 [DRY RUN] Would copy activities...", options: options)
260
+ source_activities = source_client.activities.where(project_id: source_project.id).all
261
+ log(" Found #{source_activities.count} activities to copy", options: options)
262
+ end
263
+
264
+ log("\n" + "=" * 80, options: options)
265
+ log("DRY RUN COMPLETE - No changes made", options: options)
266
+ log("=" * 80, options: options)
267
+ exit 0
268
+ end
269
+
270
+ # Create the project in the target
271
+ log("\nCreating project in target...", options: options)
272
+
273
+ # Access attributes directly from the source project
274
+ source_attrs = source_project.instance_variable_get(:@attributes)
275
+
276
+ project_attrs = {
277
+ name: source_project.name,
278
+ identifier: source_project.identifier,
279
+ customer_id: target_customer.id,
280
+ currency: source_project.currency,
281
+ billable: source_project.billable,
282
+ fixed_price: source_attrs[:fixed_price] || false,
283
+ retainer: source_attrs[:retainer] || false,
284
+ finish_date: source_attrs[:finish_date],
285
+ start_date: source_attrs[:start_date]
286
+ }
287
+
288
+ # Get a default user from the target instance as leader
289
+ # TODO: implement proper user mapping between instances (match by name/email)
290
+ target_users = target_client.users.all
291
+ if target_users.empty?
292
+ raise "No users found in target instance - cannot set project leader"
293
+ end
294
+ project_attrs[:leader_id] = target_users.first.id
295
+ log("Using default leader: #{target_users.first.firstname} #{target_users.first.lastname} (ID: #{target_users.first.id})", verbose: true, options: options)
296
+
297
+ # Add optional attributes if they exist
298
+ project_attrs[:co_leader_id] = get_id(source_attrs[:co_leader]) if source_attrs[:co_leader]
299
+ project_attrs[:budget] = source_attrs[:budget] if source_attrs[:budget]
300
+ project_attrs[:budget_monthly] = source_attrs[:budget_monthly] if source_attrs[:budget_monthly]
301
+ project_attrs[:budget_expenses] = source_attrs[:budget_expenses] if source_attrs[:budget_expenses]
302
+ project_attrs[:hourly_rate] = source_attrs[:hourly_rate] if source_attrs[:hourly_rate]
303
+ project_attrs[:custom_properties] = source_attrs[:custom_properties] if source_attrs[:custom_properties]
304
+ project_attrs[:labels] = source_attrs[:labels] if source_attrs[:labels]
305
+ project_attrs[:tags] = source_attrs[:tags] if source_attrs[:tags]
306
+ project_attrs[:info] = source_attrs[:info] if source_attrs[:info]
307
+ project_attrs[:billing_address] = source_attrs[:billing_address] if source_attrs[:billing_address]
308
+ project_attrs[:billing_variant] = source_attrs[:billing_variant] if source_attrs[:billing_variant]
309
+
310
+ log("Creating project: #{project_attrs[:name]}", verbose: true, options: options)
311
+ target_project = target_client.projects.create(project_attrs)
312
+ log("✅ Created project: #{target_project.name} (ID: #{target_project.id})", options: options)
313
+
314
+ # Copy tasks
315
+ task_mapping = copy_tasks(source_project, target_project, target_client, options)
316
+
317
+ # Copy activities if requested
318
+ if options[:copy_activities]
319
+ copy_activities(source_project, target_project, task_mapping, source_client, target_client, options)
320
+ else
321
+ log("\nSkipping activities (--no-activities specified)", options: options)
322
+ end
323
+
324
+ log("\n" + "=" * 80, options: options)
325
+ log("✅ PROJECT COPY COMPLETE", options: options)
326
+ log("=" * 80, options: options)
327
+ log("Source project: #{source_project.name} (#{source_instance})", options: options)
328
+ log("Target project: #{target_project.name} (#{target_instance})", options: options)
329
+ log("Target project ID: #{target_project.id}", options: options)
330
+ log("Target project URL: https://#{target_instance}.mocoapp.com/projects/#{target_project.id}", options: options)
331
+ log("=" * 80, options: options)
332
+
333
+ rescue => e
334
+ warn "\n❌ Error: #{e.message}"
335
+ warn e.backtrace.join("\n") if options[:verbose]
336
+ exit 1
337
+ end
@@ -32,6 +32,14 @@ module MOCO
32
32
 
33
33
  # Fetches tasks associated with this project.
34
34
  def tasks
35
+ # If tasks are already embedded in the attributes (e.g., from projects.assigned),
36
+ # return them directly instead of making a new API call
37
+ embedded_tasks = attributes[:tasks]
38
+ if embedded_tasks.is_a?(Array) && embedded_tasks.all? { |t| t.is_a?(MOCO::Task) }
39
+ return embedded_tasks
40
+ end
41
+
42
+ # Otherwise, create a proxy for fetching tasks via API
35
43
  # Don't cache the proxy - create a fresh one each time
36
44
  # This ensures we get fresh data when tasks are created/updated/deleted
37
45
  MOCO::NestedCollectionProxy.new(client, self, :tasks, "Task")
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
 
@@ -17,12 +18,15 @@ module MOCO
17
18
  @filters = args.fetch(:filters, {})
18
19
  @dry_run = args.fetch(:dry_run, false)
19
20
  @debug = args.fetch(:debug, false)
21
+ @default_task_name = args.fetch(:default_task_name, nil)
20
22
 
21
23
  @project_mapping = {}
22
24
  @task_mapping = {}
25
+ @default_task_cache = {} # Cache default tasks per project
23
26
 
24
27
  fetch_assigned_projects
25
28
  build_initial_mappings
29
+ create_missing_tasks_for_activities
26
30
  end
27
31
 
28
32
  # rubocop:todo Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
@@ -420,6 +424,110 @@ module MOCO
420
424
  end
421
425
  end
422
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}')"
514
+ end
515
+ end
516
+ end
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
+
423
531
  def match_project(target_project)
424
532
  # Create array of search objects manually since we can't call map on EntityCollection
425
533
  searchable_projects = []
@@ -436,19 +544,17 @@ module MOCO
436
544
  end
437
545
 
438
546
  def match_task(target_task, source_project)
439
- # Get tasks from the source project
547
+ # Get tasks from the source project (embedded in projects.assigned response)
440
548
  tasks = source_project.tasks
441
549
 
442
- # 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?
443
552
 
444
- # Manually iterate through tasks
553
+ # Create array of search objects for fuzzy matching
445
554
  searchable_tasks = tasks.map do |task|
446
555
  { original: task, name: task.name }
447
556
  end
448
557
 
449
- # Only proceed if we have tasks to match against
450
- return nil if searchable_tasks.empty?
451
-
452
558
  matcher = FuzzyMatch.new(searchable_tasks, read: :name)
453
559
  match = matcher.find(target_task.name, threshold: @task_match_threshold)
454
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"
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/sync_activity.rb CHANGED
@@ -11,7 +11,8 @@ options = {
11
11
  project: nil,
12
12
  match_project_threshold: 0.8,
13
13
  match_task_threshold: 0.45,
14
- debug: false
14
+ debug: false,
15
+ default_task: nil
15
16
  }
16
17
 
17
18
  OptionParser.new do |opts|
@@ -52,6 +53,10 @@ OptionParser.new do |opts|
52
53
  opts.on("-d", "--debug", "Enable debug output") do
53
54
  options[:debug] = true
54
55
  end
56
+
57
+ opts.on("--default-task TASK_NAME", "Default task name to map unmatched tasks to (avoids creating new tasks)") do |task_name|
58
+ options[:default_task] = task_name
59
+ end
55
60
  end.parse!
56
61
 
57
62
  source_instance = ARGV.shift
@@ -82,7 +87,8 @@ syncer = MOCO::Sync.new(
82
87
  target: options.slice(:from, :to)
83
88
  },
84
89
  dry_run: options[:dry_run],
85
- debug: options[:debug]
90
+ debug: options[:debug],
91
+ default_task_name: options[:default_task]
86
92
  )
87
93
 
88
94
  syncer.source_projects.each do |project|
metadata CHANGED
@@ -1,43 +1,43 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: moco-ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.1.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-10-08 00:00:00.000000000 Z
11
+ date: 2026-01-09 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
15
15
  requirement: !ruby/object:Gem::Requirement
16
16
  requirements:
17
- - - "~>"
17
+ - - ">="
18
18
  - !ruby/object:Gem::Version
19
19
  version: '7.0'
20
20
  type: :runtime
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
- - - "~>"
24
+ - - ">="
25
25
  - !ruby/object:Gem::Version
26
26
  version: '7.0'
27
27
  - !ruby/object:Gem::Dependency
28
28
  name: faraday
29
29
  requirement: !ruby/object:Gem::Requirement
30
30
  requirements:
31
- - - "~>"
31
+ - - ">="
32
32
  - !ruby/object:Gem::Version
33
- version: 2.9.0
33
+ version: '2.0'
34
34
  type: :runtime
35
35
  prerelease: false
36
36
  version_requirements: !ruby/object:Gem::Requirement
37
37
  requirements:
38
- - - "~>"
38
+ - - ">="
39
39
  - !ruby/object:Gem::Version
40
- version: 2.9.0
40
+ version: '2.0'
41
41
  - !ruby/object:Gem::Dependency
42
42
  name: fuzzy_match
43
43
  requirement: !ruby/object:Gem::Requirement
@@ -69,7 +69,9 @@ files:
69
69
  - README.md
70
70
  - Rakefile
71
71
  - config.yml.sample
72
+ - copy_project.rb
72
73
  - examples/v2_api_example.rb
74
+ - lib/moco-ruby.rb
73
75
  - lib/moco.rb
74
76
  - lib/moco/client.rb
75
77
  - lib/moco/collection_proxy.rb
@@ -94,7 +96,6 @@ files:
94
96
  - lib/moco/nested_collection_proxy.rb
95
97
  - lib/moco/sync.rb
96
98
  - lib/moco/version.rb
97
- - moco.gemspec
98
99
  - mocurl.rb
99
100
  - sync_activity.rb
100
101
  homepage: https://github.com/starsong-consulting/moco-ruby
data/moco.gemspec DELETED
@@ -1,36 +0,0 @@
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