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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +36 -1
- data/Gemfile.lock +47 -43
- data/README.md +63 -24
- data/copy_project.rb +337 -0
- data/lib/moco/client.rb +58 -0
- data/lib/moco/connection.rb +45 -22
- data/lib/moco/entities/activity.rb +31 -1
- data/lib/moco/entities/catalog_service.rb +54 -0
- data/lib/moco/entities/comment.rb +61 -0
- data/lib/moco/entities/company.rb +57 -2
- data/lib/moco/entities/contact.rb +56 -0
- data/lib/moco/entities/custom_property.rb +49 -0
- data/lib/moco/entities/deal.rb +38 -2
- data/lib/moco/entities/deal_category.rb +27 -0
- data/lib/moco/entities/employment.rb +55 -0
- data/lib/moco/entities/expense.rb +37 -2
- data/lib/moco/entities/expense_template.rb +39 -0
- data/lib/moco/entities/fixed_cost.rb +30 -0
- data/lib/moco/entities/holiday.rb +33 -2
- data/lib/moco/entities/hourly_rate.rb +33 -0
- data/lib/moco/entities/internal_hourly_rate.rb +32 -0
- data/lib/moco/entities/invoice.rb +81 -1
- data/lib/moco/entities/invoice_bookkeeping_export.rb +33 -0
- data/lib/moco/entities/invoice_payment.rb +51 -0
- data/lib/moco/entities/invoice_reminder.rb +51 -0
- data/lib/moco/entities/offer.rb +122 -0
- data/lib/moco/entities/offer_approval.rb +42 -0
- data/lib/moco/entities/payment_schedule.rb +48 -0
- data/lib/moco/entities/planning_entry.rb +43 -2
- data/lib/moco/entities/presence.rb +34 -2
- data/lib/moco/entities/profile.rb +24 -0
- data/lib/moco/entities/project.rb +76 -2
- data/lib/moco/entities/project_contract.rb +50 -0
- data/lib/moco/entities/project_group.rb +38 -0
- data/lib/moco/entities/purchase.rb +90 -0
- data/lib/moco/entities/purchase_bookkeeping_export.rb +34 -0
- data/lib/moco/entities/purchase_budget.rb +47 -0
- data/lib/moco/entities/purchase_category.rb +38 -0
- data/lib/moco/entities/purchase_draft.rb +25 -0
- data/lib/moco/entities/purchase_payment.rb +51 -0
- data/lib/moco/entities/receipt.rb +55 -0
- data/lib/moco/entities/recurring_expense.rb +55 -0
- data/lib/moco/entities/reports/absences.rb +16 -0
- data/lib/moco/entities/reports/cashflow.rb +16 -0
- data/lib/moco/entities/reports/finance.rb +16 -0
- data/lib/moco/entities/reports/utilization.rb +16 -0
- data/lib/moco/entities/schedule.rb +39 -2
- data/lib/moco/entities/tag.rb +30 -0
- data/lib/moco/entities/tagging.rb +27 -0
- data/lib/moco/entities/task.rb +25 -2
- data/lib/moco/entities/task_template.rb +38 -0
- data/lib/moco/entities/unit.rb +36 -0
- data/lib/moco/entities/user.rb +50 -2
- data/lib/moco/entities/user_role.rb +29 -0
- data/lib/moco/entities/vat_code_purchase.rb +29 -0
- data/lib/moco/entities/vat_code_sale.rb +29 -0
- data/lib/moco/entities/web_hook.rb +32 -2
- data/lib/moco/entities/work_time_adjustment.rb +51 -0
- data/lib/moco/sync.rb +112 -6
- data/lib/moco/version.rb +1 -1
- data/lib/moco-ruby.rb +6 -0
- data/lib/moco.rb +51 -1
- data/moco.gemspec +5 -3
- data/sync_activity.rb +8 -2
- metadata +54 -14
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7527d151ba5f10b9406932d5f6e488d73897fab49ef9ab9a89341166adde7d42
|
|
4
|
+
data.tar.gz: ce1efd8024e78ab0541229172735ac27c8ec4595eeaf86d3183bc09d72b59504
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ecad34b5401422a64351c4814c262ad9328bde6cdfa096f2295ae58b5aa56aa015dbd2c06905a6c212a6efa3b13236c45df12dd8534c15861d61dbb5071630bd
|
|
7
|
+
data.tar.gz: fe46f175939e575b1b63459a10b2ababb5137e4fae30230e59ea45185c996f6e52e9b56ecc11184a2a8fc8a7c28deda74d134594a0940cc3358d7100b4cd735b
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,39 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [1.2.0] - 2026-01-14
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- Complete MOCO API coverage with 48+ entity types
|
|
9
|
+
- New entities: Offer, Contact, Tagging, DealCategory, CatalogService, CustomProperty,
|
|
10
|
+
ExpenseTemplate, FixedCost, HourlyRate, InternalHourlyRate, TaskTemplate, UserRole,
|
|
11
|
+
VatCodeSale, VatCodePurchase, Profile, WorkTimeAdjustment, ProjectContract,
|
|
12
|
+
PaymentSchedule, RecurringExpense, InvoicePayment, InvoiceReminder, OfferApproval,
|
|
13
|
+
PurchaseCategory, PurchaseDraft, ProjectGroup, InvoiceBookkeepingExport,
|
|
14
|
+
PurchaseBookkeepingExport, PurchaseBudget, PurchasePayment
|
|
15
|
+
- Inline attribute documentation for all entity classes
|
|
16
|
+
- Reports API support via `moco.reports.absences`, `.cashflow`, `.finance`, `.utilization`
|
|
17
|
+
- Integration tests for all entity types
|
|
18
|
+
- GitHub Actions CI for tests and auto-release
|
|
19
|
+
- Ruby 4.0 support
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
- Debug output now correctly shows request body for POST/PUT/PATCH requests
|
|
23
|
+
|
|
24
|
+
## [1.1.0] - 2025-11-15
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
- Support for limited-permission accounts with auto-create missing tasks
|
|
28
|
+
- `--default-task` flag for limited-permission accounts
|
|
29
|
+
- `copy_project` tool for copying projects between MOCO instances
|
|
30
|
+
|
|
31
|
+
### Fixed
|
|
32
|
+
- Enable auto-require for Bundler
|
|
33
|
+
- Loosen dependency constraints for Rails 8 compatibility
|
|
34
|
+
|
|
35
|
+
### Security
|
|
36
|
+
- Update rexml to 3.4.4 to fix CVE-2025-58767
|
|
37
|
+
|
|
5
38
|
## [1.0.0] - 2025-10-08
|
|
6
39
|
|
|
7
40
|
### Fixed
|
|
@@ -125,7 +158,9 @@
|
|
|
125
158
|
## [0.1.0] - 2024-02-27
|
|
126
159
|
- Initial release
|
|
127
160
|
|
|
128
|
-
[Unreleased]: https://github.com/starsong-consulting/moco-ruby/compare/v1.
|
|
161
|
+
[Unreleased]: https://github.com/starsong-consulting/moco-ruby/compare/v1.2.0...HEAD
|
|
162
|
+
[1.2.0]: https://github.com/starsong-consulting/moco-ruby/compare/v1.1.0...v1.2.0
|
|
163
|
+
[1.1.0]: https://github.com/starsong-consulting/moco-ruby/compare/v1.0.0...v1.1.0
|
|
129
164
|
[1.0.0]: https://github.com/starsong-consulting/moco-ruby/compare/v1.0.0.beta...v1.0.0
|
|
130
165
|
[1.0.0.beta]: https://github.com/starsong-consulting/moco-ruby/compare/v1.0.0.alpha...v1.0.0.beta
|
|
131
166
|
[1.0.0.alpha]: https://github.com/starsong-consulting/moco-ruby/compare/v1.0.0.alpha-initial...v1.0.0.alpha
|
data/Gemfile.lock
CHANGED
|
@@ -1,67 +1,70 @@
|
|
|
1
1
|
PATH
|
|
2
2
|
remote: .
|
|
3
3
|
specs:
|
|
4
|
-
moco-ruby (1.
|
|
5
|
-
activesupport (
|
|
6
|
-
faraday (
|
|
4
|
+
moco-ruby (1.2.0)
|
|
5
|
+
activesupport (>= 7.0)
|
|
6
|
+
faraday (>= 2.0)
|
|
7
7
|
fuzzy_match (~> 2.1.0)
|
|
8
8
|
|
|
9
9
|
GEM
|
|
10
10
|
remote: https://rubygems.org/
|
|
11
11
|
specs:
|
|
12
|
-
activesupport (
|
|
12
|
+
activesupport (8.1.2)
|
|
13
13
|
base64
|
|
14
|
-
benchmark (>= 0.3)
|
|
15
14
|
bigdecimal
|
|
16
15
|
concurrent-ruby (~> 1.0, >= 1.3.1)
|
|
17
16
|
connection_pool (>= 2.2.5)
|
|
18
17
|
drb
|
|
19
18
|
i18n (>= 1.6, < 2)
|
|
19
|
+
json
|
|
20
20
|
logger (>= 1.4.2)
|
|
21
21
|
minitest (>= 5.1)
|
|
22
22
|
securerandom (>= 0.3)
|
|
23
23
|
tzinfo (~> 2.0, >= 2.0.5)
|
|
24
|
-
|
|
25
|
-
|
|
24
|
+
uri (>= 0.13.1)
|
|
25
|
+
addressable (2.8.8)
|
|
26
|
+
public_suffix (>= 2.0.2, < 8.0)
|
|
26
27
|
ast (2.4.3)
|
|
27
|
-
base64 (0.
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
crack (1.0.0)
|
|
28
|
+
base64 (0.3.0)
|
|
29
|
+
bigdecimal (4.0.1)
|
|
30
|
+
concurrent-ruby (1.3.6)
|
|
31
|
+
connection_pool (3.0.2)
|
|
32
|
+
crack (1.0.1)
|
|
33
33
|
bigdecimal
|
|
34
34
|
rexml
|
|
35
35
|
dotenv (2.8.1)
|
|
36
|
-
drb (2.2.
|
|
37
|
-
faraday (2.
|
|
38
|
-
faraday-net_http (>= 2.0, < 3.
|
|
39
|
-
|
|
40
|
-
|
|
36
|
+
drb (2.2.3)
|
|
37
|
+
faraday (2.14.0)
|
|
38
|
+
faraday-net_http (>= 2.0, < 3.5)
|
|
39
|
+
json
|
|
40
|
+
logger
|
|
41
|
+
faraday-net_http (3.4.2)
|
|
42
|
+
net-http (~> 0.5)
|
|
41
43
|
fuzzy_match (2.1.0)
|
|
42
|
-
hashdiff (1.1
|
|
43
|
-
i18n (1.14.
|
|
44
|
+
hashdiff (1.2.1)
|
|
45
|
+
i18n (1.14.8)
|
|
44
46
|
concurrent-ruby (~> 1.0)
|
|
45
|
-
json (2.
|
|
46
|
-
language_server-protocol (3.17.0.
|
|
47
|
+
json (2.18.0)
|
|
48
|
+
language_server-protocol (3.17.0.5)
|
|
47
49
|
lint_roller (1.1.0)
|
|
48
50
|
logger (1.7.0)
|
|
49
|
-
minitest (
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
51
|
+
minitest (6.0.1)
|
|
52
|
+
prism (~> 1.5)
|
|
53
|
+
net-http (0.9.1)
|
|
54
|
+
uri (>= 0.11.1)
|
|
55
|
+
parallel (1.27.0)
|
|
56
|
+
parser (3.3.10.0)
|
|
54
57
|
ast (~> 2.4.1)
|
|
55
58
|
racc
|
|
56
|
-
power_assert (
|
|
57
|
-
prism (1.
|
|
58
|
-
public_suffix (
|
|
59
|
+
power_assert (3.0.1)
|
|
60
|
+
prism (1.7.0)
|
|
61
|
+
public_suffix (7.0.2)
|
|
59
62
|
racc (1.8.1)
|
|
60
63
|
rainbow (3.1.1)
|
|
61
|
-
rake (13.
|
|
62
|
-
regexp_parser (2.
|
|
63
|
-
rexml (3.4.
|
|
64
|
-
rubocop (1.
|
|
64
|
+
rake (13.3.1)
|
|
65
|
+
regexp_parser (2.11.3)
|
|
66
|
+
rexml (3.4.4)
|
|
67
|
+
rubocop (1.82.1)
|
|
65
68
|
json (~> 2.3)
|
|
66
69
|
language_server-protocol (~> 3.17.0.2)
|
|
67
70
|
lint_roller (~> 1.1.0)
|
|
@@ -69,23 +72,23 @@ GEM
|
|
|
69
72
|
parser (>= 3.3.0.2)
|
|
70
73
|
rainbow (>= 2.2.2, < 4.0)
|
|
71
74
|
regexp_parser (>= 2.9.3, < 3.0)
|
|
72
|
-
rubocop-ast (>= 1.
|
|
75
|
+
rubocop-ast (>= 1.48.0, < 2.0)
|
|
73
76
|
ruby-progressbar (~> 1.7)
|
|
74
77
|
unicode-display_width (>= 2.4.0, < 4.0)
|
|
75
|
-
rubocop-ast (1.
|
|
78
|
+
rubocop-ast (1.49.0)
|
|
76
79
|
parser (>= 3.3.7.2)
|
|
77
|
-
prism (~> 1.
|
|
80
|
+
prism (~> 1.7)
|
|
78
81
|
ruby-progressbar (1.13.0)
|
|
79
82
|
securerandom (0.4.1)
|
|
80
|
-
test-unit (3.
|
|
83
|
+
test-unit (3.7.7)
|
|
81
84
|
power_assert
|
|
82
85
|
tzinfo (2.0.6)
|
|
83
86
|
concurrent-ruby (~> 1.0)
|
|
84
|
-
unicode-display_width (3.
|
|
85
|
-
unicode-emoji (~> 4.
|
|
86
|
-
unicode-emoji (4.0
|
|
87
|
-
uri (1.
|
|
88
|
-
webmock (3.
|
|
87
|
+
unicode-display_width (3.2.0)
|
|
88
|
+
unicode-emoji (~> 4.1)
|
|
89
|
+
unicode-emoji (4.2.0)
|
|
90
|
+
uri (1.1.1)
|
|
91
|
+
webmock (3.26.1)
|
|
89
92
|
addressable (>= 2.8.0)
|
|
90
93
|
crack (>= 0.3.2)
|
|
91
94
|
hashdiff (>= 0.4.0, < 2.0.0)
|
|
@@ -93,6 +96,7 @@ GEM
|
|
|
93
96
|
PLATFORMS
|
|
94
97
|
arm64-darwin-22
|
|
95
98
|
arm64-darwin-23
|
|
99
|
+
x86_64-linux
|
|
96
100
|
|
|
97
101
|
DEPENDENCIES
|
|
98
102
|
dotenv (~> 2.8)
|
data/README.md
CHANGED
|
@@ -167,25 +167,54 @@ puts "Billable tasks: #{billable_tasks.map(&:name).join(', ')}"
|
|
|
167
167
|
dev_task = project.tasks.find_by(name: "Development")
|
|
168
168
|
```
|
|
169
169
|
|
|
170
|
+
### Profile
|
|
171
|
+
|
|
172
|
+
Access the current user's profile:
|
|
173
|
+
|
|
174
|
+
```ruby
|
|
175
|
+
profile = moco.profile
|
|
176
|
+
puts "Logged in as: #{profile.firstname} #{profile.lastname}"
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
### Reports
|
|
180
|
+
|
|
181
|
+
Access read-only report endpoints:
|
|
182
|
+
|
|
183
|
+
```ruby
|
|
184
|
+
# Absences report
|
|
185
|
+
absences = moco.reports.absences(year: 2024)
|
|
186
|
+
|
|
187
|
+
# Utilization report (requires date range)
|
|
188
|
+
utilization = moco.reports.utilization(from: "2024-01-01", to: "2024-12-31")
|
|
189
|
+
|
|
190
|
+
# Financial reports
|
|
191
|
+
cashflow = moco.reports.cashflow(from: "2024-01-01", to: "2024-03-31")
|
|
192
|
+
finance = moco.reports.finance(from: "2024-01-01", to: "2024-03-31")
|
|
193
|
+
```
|
|
194
|
+
|
|
170
195
|
### Supported Entities
|
|
171
196
|
|
|
172
197
|
The gem supports all MOCO API entities with a Ruby-esque interface:
|
|
173
198
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
199
|
+
**Core:**
|
|
200
|
+
`Project`, `Activity`, `User`, `Company`, `Task`, `Invoice`, `Deal`, `Expense`, `WebHook`, `Schedule`, `Presence`, `Holiday`, `PlanningEntry`
|
|
201
|
+
|
|
202
|
+
**Business:**
|
|
203
|
+
`Contact`, `Offer`, `Purchase`, `Receipt`, `Comment`, `Tag`, `Tagging`, `DealCategory`, `ProjectGroup`, `Unit`
|
|
204
|
+
|
|
205
|
+
**Account Settings:**
|
|
206
|
+
`CatalogService`, `CustomProperty`, `ExpenseTemplate`, `FixedCost`, `HourlyRate`, `InternalHourlyRate`, `TaskTemplate`, `UserRole`
|
|
207
|
+
|
|
208
|
+
**Financial:**
|
|
209
|
+
`VatCodeSale`, `VatCodePurchase`, `PurchaseCategory`, `PurchaseDraft`, `PurchaseBudget`, `PurchasePayment`
|
|
210
|
+
|
|
211
|
+
**Bookkeeping:**
|
|
212
|
+
`InvoiceBookkeepingExport`, `PurchaseBookkeepingExport`
|
|
213
|
+
|
|
214
|
+
**Nested Resources:**
|
|
215
|
+
`Employment`, `WorkTimeAdjustment`, `ProjectContract`, `PaymentSchedule`, `RecurringExpense`, `InvoicePayment`, `InvoiceReminder`, `OfferApproval`
|
|
216
|
+
|
|
217
|
+
Access them via the client using their plural, snake_case names (e.g., `moco.planning_entries`, `moco.vat_code_sales`).
|
|
189
218
|
|
|
190
219
|
## Utilities
|
|
191
220
|
|
|
@@ -226,26 +255,36 @@ Usage: sync_activity.rb [options] source_subdomain target_subdomain
|
|
|
226
255
|
--match-project-threshold VALUE
|
|
227
256
|
Fuzzy match threshold for projects (0.0 - 1.0), default 0.8
|
|
228
257
|
--match-task-threshold VALUE Fuzzy match threshold for tasks (0.0 - 1.0), default 0.45
|
|
258
|
+
--default-task TASK_NAME Map unmatched tasks to this default task instead of creating new tasks
|
|
259
|
+
-d, --debug Enable debug output
|
|
229
260
|
-h, --help Show this message
|
|
230
261
|
```
|
|
231
262
|
**Example:** `sync_activity.rb --from 2024-04-01 --to 2024-04-10 --dry-run source-instance target-instance`
|
|
232
263
|
|
|
264
|
+
**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:
|
|
265
|
+
|
|
266
|
+
```bash
|
|
267
|
+
sync_activity.rb --from 2024-04-01 --to 2024-04-10 --default-task "Other" source-instance target-instance
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
This will map any unmatched source tasks to a task named "Other" in the corresponding target project, avoiding the need to create new tasks.
|
|
271
|
+
|
|
233
272
|
## Development
|
|
234
273
|
|
|
235
274
|
After checking out the repo, run `bin/setup` to install dependencies.
|
|
236
275
|
|
|
237
276
|
### Running Tests
|
|
238
277
|
|
|
239
|
-
The gem includes
|
|
278
|
+
The gem includes unit tests (mocked) and integration tests (live API):
|
|
240
279
|
|
|
241
280
|
```bash
|
|
242
|
-
#
|
|
243
|
-
ruby test/test_v2_api.rb
|
|
244
|
-
ruby test/
|
|
245
|
-
ruby test/test_holidays_expenses.rb # Holidays & Expenses tests (requires .env)
|
|
281
|
+
# Unit tests (mocked, fast)
|
|
282
|
+
bundle exec ruby -Ilib -Itest test/test_v2_api.rb
|
|
283
|
+
bundle exec ruby -Ilib -Itest test/test_new_entities.rb
|
|
246
284
|
|
|
247
|
-
#
|
|
248
|
-
ruby test/
|
|
285
|
+
# Integration tests (requires .env credentials)
|
|
286
|
+
bundle exec ruby -Ilib -Itest test/test_integration.rb
|
|
287
|
+
bundle exec ruby -Ilib -Itest test/test_comprehensive.rb
|
|
249
288
|
```
|
|
250
289
|
|
|
251
290
|
For integration tests, create a `.env` file with your test instance credentials:
|
|
@@ -254,7 +293,7 @@ MOCO_API_TEST_SUBDOMAIN=your-test-subdomain
|
|
|
254
293
|
MOCO_API_TEST_API_KEY=your-test-api-key
|
|
255
294
|
```
|
|
256
295
|
|
|
257
|
-
**Note:** The MOCO API has rate limits
|
|
296
|
+
**Note:** The MOCO API has rate limits. The gem automatically retries rate-limited requests with exponential backoff.
|
|
258
297
|
|
|
259
298
|
### Installation
|
|
260
299
|
|
|
@@ -268,4 +307,4 @@ Bug reports and pull requests are welcome on GitHub at https://github.com/starso
|
|
|
268
307
|
|
|
269
308
|
## License
|
|
270
309
|
|
|
271
|
-
The gem is available as open source under the terms of the [
|
|
310
|
+
The gem is available as open source under the terms of the [Apache License, Version 2.0](https://www.apache.org/licenses/LICENSE-2.0).
|
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
|