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 +4 -4
- data/Gemfile.lock +3 -3
- data/README.md +10 -0
- data/copy_project.rb +337 -0
- data/lib/moco/entities/project.rb +8 -0
- data/lib/moco/sync.rb +112 -6
- data/lib/moco/version.rb +1 -1
- data/lib/moco-ruby.rb +6 -0
- data/sync_activity.rb +8 -2
- metadata +10 -9
- data/moco.gemspec +0 -36
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5e60f0a59b12881fc99571967ff77dd1ae8c55ff8f3d45cd467bed9e6bf85c5d
|
|
4
|
+
data.tar.gz: a2070fba649482eb59f4ab96367cc5d8f12b5bfb0f6923bc7e7f6d2c69135566
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 (
|
|
6
|
-
faraday (
|
|
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.
|
|
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
|
-
#
|
|
550
|
+
# Only proceed if we have tasks to match against
|
|
551
|
+
return nil if tasks.empty?
|
|
443
552
|
|
|
444
|
-
#
|
|
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
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.
|
|
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:
|
|
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.
|
|
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.
|
|
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
|