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
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/CHANGELOG.md
CHANGED
|
@@ -1,7 +1,43 @@
|
|
|
1
|
-
# Changelog
|
|
1
|
+
# # Changelog
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [1.0.0] - 2025-10-08
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
- Fixed Project `leader` and `co_leader` associations to return User objects instead of Hashes
|
|
9
|
+
- Fixed Expense associations to use proper `association()` method for embedded objects
|
|
10
|
+
- Fixed nested resource proxy caching issue - tasks and expenses now always return fresh data
|
|
11
|
+
- Fixed `NestedCollectionProxy` path construction for expenses (was double-prefixing with "projects/")
|
|
12
|
+
- Fixed `entity_path` warnings by converting instance methods to class methods in Holiday and WebHook classes
|
|
13
|
+
- Fixed embedded entity conversion for leader, co_leader, and user associations in BaseEntity
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
- Added comprehensive test suite using test-unit framework:
|
|
17
|
+
- `test/test_comprehensive.rb` - 27 integration tests covering all CRUD operations
|
|
18
|
+
- `test/test_holidays_expenses.rb` - 9 tests for holidays and expenses (nested resources)
|
|
19
|
+
- `test/test_v2_api.rb` - 4 unit tests with mocked API responses
|
|
20
|
+
- `test/test_helper.rb` - Shared test configuration
|
|
21
|
+
- Added `Project#expenses` method for nested expense access
|
|
22
|
+
- Added proper entity type mappings for `:leader`, `:co_leader`, and `:user` in BaseEntity
|
|
23
|
+
- Added dotenv gem as development dependency for test environment configuration
|
|
24
|
+
|
|
25
|
+
### Changed
|
|
26
|
+
- Moved all `require_relative` statements out of methods and into file-level requires
|
|
27
|
+
- Improved load order in `lib/moco.rb` - core classes now load before entities
|
|
28
|
+
- Updated `Expense` entity to use `association()` method for project and user relationships
|
|
29
|
+
- Refactored nested resource access in Project class to return fresh proxies instead of cached ones
|
|
30
|
+
- Enhanced `NestedCollectionProxy` to properly handle nested resource paths without double-prefixing
|
|
31
|
+
|
|
32
|
+
### Removed
|
|
33
|
+
- Removed manual test scripts (converted to proper test-unit tests)
|
|
34
|
+
|
|
35
|
+
## [1.0.0.beta] - 2025-04-10
|
|
36
|
+
|
|
37
|
+
### Fixed
|
|
38
|
+
- Fixed activity synchronization to properly identify existing activities in target system
|
|
39
|
+
- Added remote_id accessor to Activity class to prevent duplicate activity creation
|
|
40
|
+
|
|
5
41
|
## [1.0.0.alpha] - 2025-04-10
|
|
6
42
|
|
|
7
43
|
### Added
|
|
@@ -10,7 +46,7 @@
|
|
|
10
46
|
- Supports proper path construction for nested API endpoints
|
|
11
47
|
- Implements `destroy_all` method for bulk deletion of nested resources
|
|
12
48
|
|
|
13
|
-
## [1.0.0] - 2025-04-10
|
|
49
|
+
## [1.0.0.alpha-initial] - 2025-04-10
|
|
14
50
|
|
|
15
51
|
### Added
|
|
16
52
|
- Implemented ActiveRecord-style query interface (`where`, `find`, `find_by`, `first`, `all`, `each`) via `CollectionProxy`.
|
|
@@ -89,9 +125,11 @@
|
|
|
89
125
|
## [0.1.0] - 2024-02-27
|
|
90
126
|
- Initial release
|
|
91
127
|
|
|
92
|
-
[Unreleased]: https://github.com/starsong-consulting/moco-ruby/compare/v1.0.0
|
|
93
|
-
[1.0.0
|
|
94
|
-
[1.0.0]: https://github.com/starsong-consulting/moco-ruby/compare/
|
|
128
|
+
[Unreleased]: https://github.com/starsong-consulting/moco-ruby/compare/v1.0.0...HEAD
|
|
129
|
+
[1.0.0]: https://github.com/starsong-consulting/moco-ruby/compare/v1.0.0.beta...v1.0.0
|
|
130
|
+
[1.0.0.beta]: https://github.com/starsong-consulting/moco-ruby/compare/v1.0.0.alpha...v1.0.0.beta
|
|
131
|
+
[1.0.0.alpha]: https://github.com/starsong-consulting/moco-ruby/compare/v1.0.0.alpha-initial...v1.0.0.alpha
|
|
132
|
+
[1.0.0.alpha-initial]: https://github.com/starsong-consulting/moco-ruby/compare/v0.1.2...v1.0.0.alpha-initial
|
|
95
133
|
[0.1.2]: https://github.com/starsong-consulting/moco-ruby/compare/v0.1.1...v0.1.2
|
|
96
134
|
[0.1.1]: https://github.com/starsong-consulting/moco-ruby/compare/v0.1.0...v0.1.1
|
|
97
135
|
[0.1.0]: https://github.com/starsong-consulting/moco-ruby/releases/tag/v0.1.0
|
data/Gemfile
CHANGED
data/Gemfile.lock
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
PATH
|
|
2
2
|
remote: .
|
|
3
3
|
specs:
|
|
4
|
-
moco-ruby (1.0.0
|
|
5
|
-
activesupport (
|
|
6
|
-
faraday (
|
|
4
|
+
moco-ruby (1.0.0)
|
|
5
|
+
activesupport (>= 7.0)
|
|
6
|
+
faraday (>= 2.0)
|
|
7
7
|
fuzzy_match (~> 2.1.0)
|
|
8
8
|
|
|
9
9
|
GEM
|
|
@@ -32,6 +32,7 @@ GEM
|
|
|
32
32
|
crack (1.0.0)
|
|
33
33
|
bigdecimal
|
|
34
34
|
rexml
|
|
35
|
+
dotenv (2.8.1)
|
|
35
36
|
drb (2.2.1)
|
|
36
37
|
faraday (2.9.2)
|
|
37
38
|
faraday-net_http (>= 2.0, < 3.2)
|
|
@@ -59,7 +60,7 @@ GEM
|
|
|
59
60
|
rainbow (3.1.1)
|
|
60
61
|
rake (13.2.1)
|
|
61
62
|
regexp_parser (2.10.0)
|
|
62
|
-
rexml (3.4.
|
|
63
|
+
rexml (3.4.4)
|
|
63
64
|
rubocop (1.75.2)
|
|
64
65
|
json (~> 2.3)
|
|
65
66
|
language_server-protocol (~> 3.17.0.2)
|
|
@@ -94,6 +95,7 @@ PLATFORMS
|
|
|
94
95
|
arm64-darwin-23
|
|
95
96
|
|
|
96
97
|
DEPENDENCIES
|
|
98
|
+
dotenv (~> 2.8)
|
|
97
99
|
moco-ruby!
|
|
98
100
|
rake (~> 13.0)
|
|
99
101
|
rubocop (~> 1.21)
|
data/README.md
CHANGED
|
@@ -226,15 +226,51 @@ 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
|
-
After checking out the repo, run `bin/setup` to install dependencies.
|
|
245
|
+
After checking out the repo, run `bin/setup` to install dependencies.
|
|
246
|
+
|
|
247
|
+
### Running Tests
|
|
248
|
+
|
|
249
|
+
The gem includes a comprehensive test suite with both unit tests (mocked) and integration tests (live API):
|
|
250
|
+
|
|
251
|
+
```bash
|
|
252
|
+
# Run all tests
|
|
253
|
+
ruby test/test_v2_api.rb # Unit tests (mocked, fast)
|
|
254
|
+
ruby test/test_comprehensive.rb # Integration tests (requires .env)
|
|
255
|
+
ruby test/test_holidays_expenses.rb # Holidays & Expenses tests (requires .env)
|
|
256
|
+
|
|
257
|
+
# Or run individually
|
|
258
|
+
ruby test/test_v2_api.rb
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
For integration tests, create a `.env` file with your test instance credentials:
|
|
262
|
+
```
|
|
263
|
+
MOCO_API_TEST_SUBDOMAIN=your-test-subdomain
|
|
264
|
+
MOCO_API_TEST_API_KEY=your-test-api-key
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
**Note:** The MOCO API has rate limits (120 requests per 2 minutes on standard plans). Integration tests make real API calls.
|
|
268
|
+
|
|
269
|
+
### Installation
|
|
270
|
+
|
|
271
|
+
To install this gem onto your local machine, run `bundle exec rake install`.
|
|
236
272
|
|
|
237
|
-
To
|
|
273
|
+
To release a new version, update the version number in `version.rb`, update the `CHANGELOG.md`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
|
|
238
274
|
|
|
239
275
|
## Contributing
|
|
240
276
|
|
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
|
data/lib/moco/client.rb
CHANGED
|
@@ -6,8 +6,8 @@ module MOCO
|
|
|
6
6
|
class Client
|
|
7
7
|
attr_reader :connection
|
|
8
8
|
|
|
9
|
-
def initialize(subdomain:, api_key:)
|
|
10
|
-
@connection = Connection.new(self, subdomain, api_key)
|
|
9
|
+
def initialize(subdomain:, api_key:, debug: false)
|
|
10
|
+
@connection = Connection.new(self, subdomain, api_key, debug: debug)
|
|
11
11
|
@collections = {}
|
|
12
12
|
end
|
|
13
13
|
|
|
@@ -54,6 +54,13 @@ module MOCO
|
|
|
54
54
|
self
|
|
55
55
|
end
|
|
56
56
|
|
|
57
|
+
# Modifies the base path to fetch assigned resources. Returns self.
|
|
58
|
+
def assigned
|
|
59
|
+
# Ensure this is only called once or handle idempotency if needed
|
|
60
|
+
@base_path += "/assigned"
|
|
61
|
+
self
|
|
62
|
+
end
|
|
63
|
+
|
|
57
64
|
# --- Methods Triggering API Call ---
|
|
58
65
|
|
|
59
66
|
# Fetches all records matching the current filters.
|
|
@@ -130,6 +137,9 @@ module MOCO
|
|
|
130
137
|
# MOCO API might use 'per_page' instead of 'limit' for pagination control
|
|
131
138
|
# Adjust if necessary based on API docs. Assuming 'limit' works for now.
|
|
132
139
|
|
|
140
|
+
# Filter out nil values before sending to avoid empty params like ?from&to=
|
|
141
|
+
query_params.compact!
|
|
142
|
+
|
|
133
143
|
response = client.get(@base_path, query_params)
|
|
134
144
|
@records = wrap_response(response) # wrap_response should return an Array here
|
|
135
145
|
@loaded = true
|
data/lib/moco/connection.rb
CHANGED
|
@@ -7,12 +7,13 @@ module MOCO
|
|
|
7
7
|
# Handles HTTP communication with the MOCO API
|
|
8
8
|
# Responsible for building API requests and converting responses to entity objects
|
|
9
9
|
class Connection
|
|
10
|
-
attr_reader :client, :subdomain, :api_key
|
|
10
|
+
attr_reader :client, :subdomain, :api_key, :debug
|
|
11
11
|
|
|
12
|
-
def initialize(client, subdomain, api_key)
|
|
12
|
+
def initialize(client, subdomain, api_key, debug: false)
|
|
13
13
|
@client = client
|
|
14
14
|
@subdomain = subdomain
|
|
15
15
|
@api_key = api_key
|
|
16
|
+
@debug = debug
|
|
16
17
|
@conn = Faraday.new do |f|
|
|
17
18
|
f.request :json
|
|
18
19
|
f.response :json
|
|
@@ -25,6 +26,11 @@ module MOCO
|
|
|
25
26
|
# These methods send the request and return the raw parsed JSON response body.
|
|
26
27
|
%w[get post put patch delete].each do |http_method|
|
|
27
28
|
define_method(http_method) do |path, params = {}|
|
|
29
|
+
# Log URL if debug is enabled
|
|
30
|
+
if @debug
|
|
31
|
+
full_url = @conn.build_url(path, params).to_s
|
|
32
|
+
warn "[DEBUG] Fetching URL: #{http_method.upcase} #{full_url}"
|
|
33
|
+
end
|
|
28
34
|
response = @conn.send(http_method, path, params)
|
|
29
35
|
|
|
30
36
|
# Raise an error for non-successful responses
|
|
@@ -89,8 +89,13 @@ module MOCO
|
|
|
89
89
|
end
|
|
90
90
|
end
|
|
91
91
|
|
|
92
|
+
# Access the remote_id attribute
|
|
93
|
+
def remote_id
|
|
94
|
+
attributes[:remote_id]
|
|
95
|
+
end
|
|
96
|
+
|
|
92
97
|
def to_s
|
|
93
|
-
"#{date} - #{hours}h - #{project&.name} - #{task&.name} - #{description}"
|
|
98
|
+
"#{attributes[:date]} - #{attributes[:hours]}h - #{project&.name} - #{task&.name} - #{attributes[:description]}"
|
|
94
99
|
end
|
|
95
100
|
end
|
|
96
101
|
end
|
|
@@ -29,6 +29,11 @@ module MOCO
|
|
|
29
29
|
define_attribute_methods
|
|
30
30
|
end
|
|
31
31
|
|
|
32
|
+
# Provides a basic string representation (can be overridden by subclasses).
|
|
33
|
+
def to_s
|
|
34
|
+
"#{self.class.name.split("::").last} ##{id}"
|
|
35
|
+
end
|
|
36
|
+
|
|
32
37
|
# Returns the entity's ID.
|
|
33
38
|
def id
|
|
34
39
|
attributes[:id] || attributes["id"]
|
|
@@ -185,9 +190,11 @@ module MOCO
|
|
|
185
190
|
# Return cached collection if available
|
|
186
191
|
return @_association_cache[association_name] if @_association_cache.key?(association_name)
|
|
187
192
|
|
|
188
|
-
# If the association data is already in attributes and is an array of entities
|
|
193
|
+
# If the association data is already in attributes and is an array of entities
|
|
194
|
+
# AND this is NOT a nested resource, use it directly
|
|
195
|
+
# For nested resources, we always create a proxy to ensure CRUD operations work
|
|
189
196
|
association_data = attributes[association_name]
|
|
190
|
-
if association_data.is_a?(Array) && association_data.all? { |item| item.is_a?(MOCO::BaseEntity) }
|
|
197
|
+
if !nested && association_data.is_a?(Array) && association_data.all? { |item| item.is_a?(MOCO::BaseEntity) }
|
|
191
198
|
return @_association_cache[association_name] = association_data
|
|
192
199
|
end
|
|
193
200
|
|
|
@@ -201,7 +208,6 @@ module MOCO
|
|
|
201
208
|
# Check if this is a nested resource
|
|
202
209
|
if nested
|
|
203
210
|
# For nested resources, create a NestedCollectionProxy
|
|
204
|
-
require_relative "../nested_collection_proxy"
|
|
205
211
|
@_association_cache[association_name] = MOCO::NestedCollectionProxy.new(
|
|
206
212
|
client,
|
|
207
213
|
self,
|
|
@@ -273,9 +279,12 @@ module MOCO
|
|
|
273
279
|
|
|
274
280
|
# Infer type from the key_hint if :type attribute is missing
|
|
275
281
|
if type_name.nil? && key_hint
|
|
276
|
-
# Special
|
|
277
|
-
type_name =
|
|
282
|
+
# Special cases: map certain keys to specific entity classes
|
|
283
|
+
type_name = case key_hint
|
|
284
|
+
when :customer
|
|
278
285
|
"Company"
|
|
286
|
+
when :leader, :co_leader, :user
|
|
287
|
+
"User"
|
|
279
288
|
else
|
|
280
289
|
# General case: singularize the key hint (e.g., :tasks -> "task")
|
|
281
290
|
ActiveSupport::Inflector.singularize(key_hint.to_s)
|
|
@@ -4,6 +4,12 @@ module MOCO
|
|
|
4
4
|
# Represents a MOCO expense
|
|
5
5
|
# Provides methods for expense-specific operations and associations
|
|
6
6
|
class Expense < BaseEntity
|
|
7
|
+
# Override entity_path to use the global expenses endpoint
|
|
8
|
+
# Note: Expenses can also be accessed via projects/{id}/expenses
|
|
9
|
+
def self.entity_path
|
|
10
|
+
"projects/expenses"
|
|
11
|
+
end
|
|
12
|
+
|
|
7
13
|
# Class methods for bulk operations
|
|
8
14
|
def self.disregard(client, expense_ids:)
|
|
9
15
|
client.post("projects/expenses/disregard", { expense_ids: })
|
|
@@ -15,11 +21,13 @@ module MOCO
|
|
|
15
21
|
|
|
16
22
|
# Associations
|
|
17
23
|
def project
|
|
18
|
-
|
|
24
|
+
# Use the association method which handles embedded objects
|
|
25
|
+
association(:project, "Project")
|
|
19
26
|
end
|
|
20
27
|
|
|
21
28
|
def user
|
|
22
|
-
|
|
29
|
+
# Use the association method which handles embedded objects
|
|
30
|
+
association(:user, "User")
|
|
23
31
|
end
|
|
24
32
|
|
|
25
33
|
def to_s
|