moco-ruby 1.0.0 → 1.2.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.
Files changed (66) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +36 -1
  3. data/Gemfile.lock +47 -43
  4. data/README.md +63 -24
  5. data/copy_project.rb +337 -0
  6. data/lib/moco/client.rb +58 -0
  7. data/lib/moco/connection.rb +45 -22
  8. data/lib/moco/entities/activity.rb +31 -1
  9. data/lib/moco/entities/catalog_service.rb +54 -0
  10. data/lib/moco/entities/comment.rb +61 -0
  11. data/lib/moco/entities/company.rb +57 -2
  12. data/lib/moco/entities/contact.rb +56 -0
  13. data/lib/moco/entities/custom_property.rb +49 -0
  14. data/lib/moco/entities/deal.rb +38 -2
  15. data/lib/moco/entities/deal_category.rb +27 -0
  16. data/lib/moco/entities/employment.rb +55 -0
  17. data/lib/moco/entities/expense.rb +37 -2
  18. data/lib/moco/entities/expense_template.rb +39 -0
  19. data/lib/moco/entities/fixed_cost.rb +30 -0
  20. data/lib/moco/entities/holiday.rb +33 -2
  21. data/lib/moco/entities/hourly_rate.rb +33 -0
  22. data/lib/moco/entities/internal_hourly_rate.rb +32 -0
  23. data/lib/moco/entities/invoice.rb +81 -1
  24. data/lib/moco/entities/invoice_bookkeeping_export.rb +33 -0
  25. data/lib/moco/entities/invoice_payment.rb +51 -0
  26. data/lib/moco/entities/invoice_reminder.rb +51 -0
  27. data/lib/moco/entities/offer.rb +122 -0
  28. data/lib/moco/entities/offer_approval.rb +42 -0
  29. data/lib/moco/entities/payment_schedule.rb +48 -0
  30. data/lib/moco/entities/planning_entry.rb +43 -2
  31. data/lib/moco/entities/presence.rb +34 -2
  32. data/lib/moco/entities/profile.rb +24 -0
  33. data/lib/moco/entities/project.rb +76 -2
  34. data/lib/moco/entities/project_contract.rb +50 -0
  35. data/lib/moco/entities/project_group.rb +38 -0
  36. data/lib/moco/entities/purchase.rb +90 -0
  37. data/lib/moco/entities/purchase_bookkeeping_export.rb +34 -0
  38. data/lib/moco/entities/purchase_budget.rb +47 -0
  39. data/lib/moco/entities/purchase_category.rb +38 -0
  40. data/lib/moco/entities/purchase_draft.rb +25 -0
  41. data/lib/moco/entities/purchase_payment.rb +51 -0
  42. data/lib/moco/entities/receipt.rb +55 -0
  43. data/lib/moco/entities/recurring_expense.rb +55 -0
  44. data/lib/moco/entities/reports/absences.rb +16 -0
  45. data/lib/moco/entities/reports/cashflow.rb +16 -0
  46. data/lib/moco/entities/reports/finance.rb +16 -0
  47. data/lib/moco/entities/reports/utilization.rb +16 -0
  48. data/lib/moco/entities/schedule.rb +39 -2
  49. data/lib/moco/entities/tag.rb +30 -0
  50. data/lib/moco/entities/tagging.rb +27 -0
  51. data/lib/moco/entities/task.rb +25 -2
  52. data/lib/moco/entities/task_template.rb +38 -0
  53. data/lib/moco/entities/unit.rb +36 -0
  54. data/lib/moco/entities/user.rb +50 -2
  55. data/lib/moco/entities/user_role.rb +29 -0
  56. data/lib/moco/entities/vat_code_purchase.rb +29 -0
  57. data/lib/moco/entities/vat_code_sale.rb +29 -0
  58. data/lib/moco/entities/web_hook.rb +32 -2
  59. data/lib/moco/entities/work_time_adjustment.rb +51 -0
  60. data/lib/moco/sync.rb +112 -6
  61. data/lib/moco/version.rb +1 -1
  62. data/lib/moco-ruby.rb +6 -0
  63. data/lib/moco.rb +51 -1
  64. data/moco.gemspec +5 -3
  65. data/sync_activity.rb +8 -2
  66. metadata +54 -14
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MOCO
4
+ module Reports
5
+ # Represents a MOCO finance report (read-only)
6
+ class Finance < BaseEntity
7
+ def self.entity_path
8
+ "report/finance"
9
+ end
10
+
11
+ def to_s
12
+ "FinanceReport"
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MOCO
4
+ module Reports
5
+ # Represents a MOCO utilization report (read-only)
6
+ class Utilization < BaseEntity
7
+ def self.entity_path
8
+ "report/utilization"
9
+ end
10
+
11
+ def to_s
12
+ "UtilizationReport"
13
+ end
14
+ end
15
+ end
16
+ end
@@ -1,8 +1,45 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module MOCO
4
- # Represents a MOCO schedule entry
5
- # Provides methods for schedule-specific associations
4
+ # Represents a MOCO schedule entry (absence/time-off)
5
+ # Note: For project planning, use PlanningEntry instead
6
+ #
7
+ # == Required attributes for create:
8
+ # date - String, "YYYY-MM-DD" date of absence
9
+ # absence_code - Integer, type of absence:
10
+ # 1 = unplannable absence
11
+ # 2 = public holiday
12
+ # 3 = sick day
13
+ # 4 = holiday/vacation
14
+ # 5 = other absence
15
+ #
16
+ # == Optional attributes:
17
+ # user_id - Integer, user ID (default: current user)
18
+ # am - Boolean, morning absence (default: true)
19
+ # pm - Boolean, afternoon absence (default: true)
20
+ # comment - String, comment/note
21
+ # symbol - Integer, 1-6 for half day visualization
22
+ #
23
+ # == Read-only attributes:
24
+ # id, assignment (Hash), user (Hash), created_at, updated_at
25
+ #
26
+ # == Example:
27
+ # # Full day vacation
28
+ # moco.schedules.create(
29
+ # date: "2024-01-15",
30
+ # absence_code: 4,
31
+ # user_id: 123,
32
+ # comment: "Annual leave"
33
+ # )
34
+ #
35
+ # # Half day sick (morning only)
36
+ # moco.schedules.create(
37
+ # date: "2024-01-16",
38
+ # absence_code: 3,
39
+ # am: true,
40
+ # pm: false
41
+ # )
42
+ #
6
43
  class Schedule < BaseEntity
7
44
  # Associations
8
45
  def user
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MOCO
4
+ # Represents a MOCO tag/label
5
+ #
6
+ # == Required attributes for create:
7
+ # name - String, tag name (e.g., "Important")
8
+ # context - String, entity type this tag applies to:
9
+ # "Project", "Contact", "Company", "Deal", "Offer",
10
+ # "Invoice", "Purchase", "User"
11
+ #
12
+ # == Read-only attributes:
13
+ # id, created_at, updated_at
14
+ #
15
+ # == Example:
16
+ # moco.tags.create(
17
+ # name: "Priority",
18
+ # context: "Project"
19
+ # )
20
+ #
21
+ # == Note:
22
+ # To apply tags to entities, use the `tags` attribute when
23
+ # creating/updating the entity: { tags: ["Tag1", "Tag2"] }
24
+ #
25
+ class Tag < BaseEntity
26
+ def to_s
27
+ name.to_s
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MOCO
4
+ # Represents a MOCO tagging (association between a tag and an entity)
5
+ # This is a read-only entity representing tag assignments.
6
+ #
7
+ # == Read-only attributes:
8
+ # id, entity_id, entity_type, tag (Hash), created_at, updated_at
9
+ #
10
+ # == Note:
11
+ # To apply tags to entities, use the `tags` attribute when
12
+ # creating/updating the entity directly:
13
+ # moco.projects.update(123, tags: ["Priority", "Important"])
14
+ #
15
+ # See also: Tag entity for managing available tags.
16
+ #
17
+ class Tagging < BaseEntity
18
+ # Associations
19
+ def tag
20
+ association(:tag)
21
+ end
22
+
23
+ def to_s
24
+ "Tagging ##{id}"
25
+ end
26
+ end
27
+ end
@@ -1,8 +1,31 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module MOCO
4
- # Represents a MOCO task
5
- # Provides methods for task-specific associations
4
+ # Represents a MOCO task (project service/activity type)
5
+ # Tasks are nested under projects: project.tasks.create(...)
6
+ #
7
+ # == Required attributes for create:
8
+ # name - String, task name (e.g., "Development", "Design / UX")
9
+ #
10
+ # == Optional attributes:
11
+ # billable - Boolean, whether time on this task is billable
12
+ # active - Boolean, whether task is active
13
+ # budget - Float/Integer, budget in hours or currency
14
+ # hourly_rate - Float/Integer, rate for this task (used if project billing_variant is "task")
15
+ # description - String, task description
16
+ #
17
+ # == Read-only attributes (returned by API):
18
+ # id, created_at, updated_at
19
+ #
20
+ # == Example:
21
+ # project = moco.projects.find(123)
22
+ # project.tasks.create(
23
+ # name: "Development",
24
+ # billable: true,
25
+ # hourly_rate: 150,
26
+ # budget: 100
27
+ # )
28
+ #
6
29
  class Task < BaseEntity
7
30
  # Associations
8
31
  def project
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MOCO
4
+ # Represents a MOCO task template (Standardleistungen)
5
+ # Pre-defined task types for projects
6
+ #
7
+ # == Required attributes for create:
8
+ # name - String, task name (e.g., "Development", "Design")
9
+ #
10
+ # == Optional attributes:
11
+ # description - String, task description
12
+ # revenue_category_id - Integer, revenue category for invoicing
13
+ # billable - Boolean, whether tasks are billable by default
14
+ # project_default - Boolean, auto-add to new projects
15
+ # index - Integer, display order (e.g., 10, 20, 30)
16
+ #
17
+ # == Read-only attributes:
18
+ # id, revenue_category (Hash), created_at, updated_at
19
+ #
20
+ # == Example:
21
+ # moco.task_templates.create(
22
+ # name: "Backend Development",
23
+ # description: "Server-side programming",
24
+ # billable: true,
25
+ # project_default: true,
26
+ # index: 10
27
+ # )
28
+ #
29
+ class TaskTemplate < BaseEntity
30
+ def self.entity_path
31
+ "account/task_templates"
32
+ end
33
+
34
+ def to_s
35
+ name.to_s
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MOCO
4
+ # Represents a MOCO unit/team (Teams)
5
+ #
6
+ # == Required attributes for create:
7
+ # name - String, team name (e.g., "Development Team")
8
+ #
9
+ # == Read-only attributes:
10
+ # id, users (Array of user hashes), created_at, updated_at
11
+ #
12
+ # == Example:
13
+ # # Create a new team
14
+ # moco.units.create(name: "Marketing Team")
15
+ #
16
+ # # Get users in a team
17
+ # team = moco.units.find(123)
18
+ # team.users # => Array of User objects
19
+ #
20
+ # == Note:
21
+ # To assign users to a team, update the user with unit_id:
22
+ # moco.users.update(user_id, unit_id: team.id)
23
+ #
24
+ # Deleting a unit is only possible if no users are assigned to it.
25
+ #
26
+ class Unit < BaseEntity
27
+ # Get users belonging to this unit
28
+ def users
29
+ has_many(:users, :unit_id)
30
+ end
31
+
32
+ def to_s
33
+ name.to_s
34
+ end
35
+ end
36
+ end
@@ -1,8 +1,44 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module MOCO
4
- # Represents a MOCO user
5
- # Provides methods for user-specific operations and associations
4
+ # Represents a MOCO user (staff member)
5
+ #
6
+ # == Required attributes for create:
7
+ # firstname - String, first name
8
+ # lastname - String, last name
9
+ # email - String, email address (used for login)
10
+ # unit_id - Integer, team/unit ID
11
+ #
12
+ # == Optional attributes:
13
+ # password - String, initial password (if not set, user gets welcome email)
14
+ # role_id - Integer, permission role ID
15
+ # active - Boolean, whether user is active
16
+ # external - Boolean, true for contractors/external staff
17
+ # language - String, one of: "de", "de-AT", "de-CH", "en", "it", "fr"
18
+ # mobile_phone - String, mobile phone number
19
+ # work_phone - String, work phone number
20
+ # home_address - String, home address (use \n for line breaks)
21
+ # bday - String, birthday "YYYY-MM-DD"
22
+ # iban - String, bank account IBAN
23
+ # tags - Array of Strings, e.g., ["Developer", "Remote"]
24
+ # custom_properties - Hash, e.g., {"Start Date": "2024-01-01"}
25
+ # info - String, additional notes
26
+ # welcome_email - Boolean, send welcome email (default: true if no password)
27
+ # avatar - Hash, { filename: "photo.jpg", base64: "..." }
28
+ #
29
+ # == Read-only attributes:
30
+ # id, avatar_url, unit (Hash), role (Hash), created_at, updated_at
31
+ #
32
+ # == Example:
33
+ # moco.users.create(
34
+ # firstname: "John",
35
+ # lastname: "Doe",
36
+ # email: "john.doe@company.com",
37
+ # unit_id: 123,
38
+ # language: "en",
39
+ # tags: ["Developer"]
40
+ # )
41
+ #
6
42
  class User < BaseEntity
7
43
  # Instance methods for user-specific operations
8
44
  def performance_report
@@ -22,6 +58,18 @@ module MOCO
22
58
  has_many(:holidays)
23
59
  end
24
60
 
61
+ def employments
62
+ has_many(:employments)
63
+ end
64
+
65
+ def work_time_adjustments
66
+ has_many(:work_time_adjustments)
67
+ end
68
+
69
+ def unit
70
+ association(:unit)
71
+ end
72
+
25
73
  def full_name
26
74
  "#{firstname} #{lastname}"
27
75
  end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MOCO
4
+ # Represents a MOCO user permission role
5
+ # Read-only list of available permission roles
6
+ #
7
+ # == Read-only attributes:
8
+ # id, name, created_at, updated_at
9
+ #
10
+ # == Common roles:
11
+ # - Admin
12
+ # - Manager
13
+ # - Coworker
14
+ # - etc.
15
+ #
16
+ # == Note:
17
+ # Permission roles are configured in MOCO's admin interface.
18
+ # Use role_id when creating/updating users to assign a role.
19
+ #
20
+ class UserRole < BaseEntity
21
+ def self.entity_path
22
+ "users/roles"
23
+ end
24
+
25
+ def to_s
26
+ name.to_s
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MOCO
4
+ # Represents a MOCO purchase VAT code (Steuerschlüssel Einkauf)
5
+ # Read-only VAT rates for purchases/receipts
6
+ #
7
+ # == Read-only attributes:
8
+ # id, code, tax, reverse_charge, intra_eu, active
9
+ #
10
+ # == Filtering:
11
+ # moco.vat_code_purchases.where(active: true)
12
+ # moco.vat_code_purchases.where(reverse_charge: true)
13
+ # moco.vat_code_purchases.where(intra_eu: true)
14
+ # moco.vat_code_purchases.where(ids: "123,456")
15
+ #
16
+ # == Note:
17
+ # VAT codes are configured in MOCO's admin interface.
18
+ # Use vat_code_id when creating purchases/receipts.
19
+ #
20
+ class VatCodePurchase < BaseEntity
21
+ def self.entity_path
22
+ "vat_code_purchases"
23
+ end
24
+
25
+ def to_s
26
+ "#{code} - #{name}"
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MOCO
4
+ # Represents a MOCO sales VAT code (Steuerschlüssel Verkauf)
5
+ # Read-only VAT rates for invoices/offers
6
+ #
7
+ # == Read-only attributes:
8
+ # id, code, tax, reverse_charge, intra_eu, active,
9
+ # print_gross_total, notice_tax_exemption, credit_account
10
+ #
11
+ # == Filtering:
12
+ # moco.vat_code_sales.where(active: true)
13
+ # moco.vat_code_sales.where(reverse_charge: true)
14
+ # moco.vat_code_sales.where(intra_eu: true) # EU intra-community
15
+ #
16
+ # == Note:
17
+ # VAT codes are configured in MOCO's admin interface.
18
+ # Use vat_code_id when creating invoices/offers.
19
+ #
20
+ class VatCodeSale < BaseEntity
21
+ def self.entity_path
22
+ "vat_code_sales"
23
+ end
24
+
25
+ def to_s
26
+ "#{code} - #{name}"
27
+ end
28
+ end
29
+ end
@@ -1,8 +1,38 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module MOCO
4
- # Represents a MOCO webhook
5
- # Provides methods for webhook-specific operations
4
+ # Represents a MOCO webhook for event notifications
5
+ #
6
+ # == Required attributes for create:
7
+ # target - String, entity type to watch:
8
+ # "Activity", "Company", "Contact", "Project",
9
+ # "Invoice", "Offer", "Deal", "Expense"
10
+ # event - String, event type: "create", "update", "delete"
11
+ # hook - String, URL to receive webhook payloads (e.g., "https://example.org/callback")
12
+ #
13
+ # == Read-only attributes:
14
+ # id, disabled, disabled_at, created_at, updated_at
15
+ #
16
+ # == Instance methods:
17
+ # enable - Enable the webhook
18
+ # disable - Disable the webhook
19
+ #
20
+ # == Example:
21
+ # # Create webhook for new activities
22
+ # moco.web_hooks.create(
23
+ # target: "Activity",
24
+ # event: "create",
25
+ # hook: "https://example.org/moco-activity-webhook"
26
+ # )
27
+ #
28
+ # # Disable a webhook
29
+ # webhook = moco.web_hooks.find(123)
30
+ # webhook.disable
31
+ #
32
+ # == Note:
33
+ # Only the `hook` URL can be updated after creation.
34
+ # To change target/event, delete and recreate the webhook.
35
+ #
6
36
  class WebHook < BaseEntity
7
37
  # Override entity_path to match API path
8
38
  def self.entity_path
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MOCO
4
+ # Represents a MOCO user work time adjustment record
5
+ # (Korrekturen Zeiterfassung) for overtime/undertime corrections
6
+ #
7
+ # == Required attributes for create:
8
+ # user_id - Integer, user to adjust
9
+ # date - String, "YYYY-MM-DD" effective date
10
+ # description - String, reason for adjustment
11
+ # hours - Float, hours to add (positive) or subtract (negative)
12
+ #
13
+ # == Read-only attributes:
14
+ # id, user (Hash), creator (Hash), created_at, updated_at
15
+ #
16
+ # == Example:
17
+ # # Add overtime from previous year
18
+ # moco.work_time_adjustments.create(
19
+ # user_id: 123,
20
+ # date: "2024-01-01",
21
+ # description: "Overtime carryover from 2023",
22
+ # hours: 42.0
23
+ # )
24
+ #
25
+ # # Correct time balance
26
+ # moco.work_time_adjustments.create(
27
+ # user_id: 123,
28
+ # date: "2024-06-15",
29
+ # description: "Correction for unpaid leave",
30
+ # hours: -16.0
31
+ # )
32
+ #
33
+ # == Filtering:
34
+ # moco.work_time_adjustments.where(user_id: 123)
35
+ # moco.work_time_adjustments.where(from: "2024-01-01", to: "2024-12-31")
36
+ #
37
+ class WorkTimeAdjustment < BaseEntity
38
+ def self.entity_path
39
+ "users/work_time_adjustments"
40
+ end
41
+
42
+ # Associations
43
+ def user
44
+ association(:user)
45
+ end
46
+
47
+ def to_s
48
+ "WorkTimeAdjustment ##{id} (#{date})"
49
+ end
50
+ end
51
+ end
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.2.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"