moco-ruby 0.1.1

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 6d7bd1d9f8ec25fb5689f497af1ad20f7481fa4df62eb794e6a0123c5c9ed495
4
+ data.tar.gz: 75e4a230361fd3f05b5392c6cb6ad8fc218c63f18f4f77b41b148fc615a231d0
5
+ SHA512:
6
+ metadata.gz: 34d936b6240f8645bf2210686a46651d6116d9073fef13f1802d6fbd07700b3fe1cc71a7bbd7044a1f79b055cc29b3f89e3266e2e4049f1b7f350f8374983ec1
7
+ data.tar.gz: 4ff707e14a5b1b327799d5feda4bb0d993b12a586d084b4c9f735333f85bb572f3abe2be4e57b5185fa8f8e269d2d9eab32c3e6561274676dcd215377eee18df
data/.rubocop.yml ADDED
@@ -0,0 +1,26 @@
1
+ AllCops:
2
+ TargetRubyVersion: 2.6
3
+ SuggestExtensions: false
4
+ NewCops: enable
5
+
6
+ Style/StringLiterals:
7
+ Enabled: true
8
+ EnforcedStyle: double_quotes
9
+
10
+ Style/StringLiteralsInInterpolation:
11
+ Enabled: true
12
+ EnforcedStyle: double_quotes
13
+
14
+ Naming/MethodParameterName:
15
+ AllowedNames:
16
+ - a
17
+ - b
18
+
19
+ Layout/LineLength:
20
+ Max: 130
21
+
22
+ Metrics/BlockLength:
23
+ Max: 40
24
+
25
+ Metrics/ClassLength:
26
+ Max: 130
data/.tool-versions ADDED
@@ -0,0 +1 @@
1
+ ruby 3.2.0
@@ -0,0 +1,21 @@
1
+ {
2
+ // Use IntelliSense to learn about possible attributes.
3
+ // Hover to view descriptions of existing attributes.
4
+ // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
5
+ "version": "0.2.0",
6
+ "configurations": [
7
+ {
8
+ "type": "rdbg",
9
+ "name": "Debug current file with rdbg",
10
+ "request": "launch",
11
+ "script": "${file}",
12
+ "args": [],
13
+ "askParameters": true
14
+ },
15
+ {
16
+ "type": "rdbg",
17
+ "name": "Attach with rdbg",
18
+ "request": "attach"
19
+ }
20
+ ]
21
+ }
data/CHANGELOG.md ADDED
@@ -0,0 +1,24 @@
1
+ # Changelog
2
+
3
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
4
+
5
+ ## [Unreleased]
6
+
7
+ ## [0.1.1] - 2024-02-27
8
+
9
+ ### Added
10
+
11
+ - Prepared for Gem release
12
+
13
+ ### Changed
14
+
15
+ - Changed target Ruby version to 2.6 (from 3.x)
16
+ - Applied Rubocop configuration and fixed style errors
17
+
18
+ ### Security
19
+
20
+ - Bumped `uri` dependency to 0.13.0
21
+
22
+ ## [0.1.0] - 2024-02-27
23
+
24
+ - Initial release
data/Gemfile ADDED
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org/"
4
+
5
+ gemspec
6
+
7
+ gem "rake", "~> 13.0"
8
+
9
+ gem "rubocop", "~> 1.21"
data/Gemfile.lock ADDED
@@ -0,0 +1,56 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ moco-ruby (0.1.1)
5
+ faraday (~> 2.9.0)
6
+ fuzzy_match (~> 2.1.0)
7
+
8
+ GEM
9
+ remote: https://rubygems.org/
10
+ specs:
11
+ ast (2.4.2)
12
+ faraday (2.9.0)
13
+ faraday-net_http (>= 2.0, < 3.2)
14
+ faraday-net_http (3.1.0)
15
+ net-http
16
+ fuzzy_match (2.1.0)
17
+ json (2.7.1)
18
+ language_server-protocol (3.17.0.3)
19
+ net-http (0.4.1)
20
+ uri
21
+ parallel (1.24.0)
22
+ parser (3.3.0.5)
23
+ ast (~> 2.4.1)
24
+ racc
25
+ racc (1.7.3)
26
+ rainbow (3.1.1)
27
+ rake (13.1.0)
28
+ regexp_parser (2.9.0)
29
+ rexml (3.2.6)
30
+ rubocop (1.60.2)
31
+ json (~> 2.3)
32
+ language_server-protocol (>= 3.17.0)
33
+ parallel (~> 1.10)
34
+ parser (>= 3.3.0.2)
35
+ rainbow (>= 2.2.2, < 4.0)
36
+ regexp_parser (>= 1.8, < 3.0)
37
+ rexml (>= 3.2.5, < 4.0)
38
+ rubocop-ast (>= 1.30.0, < 2.0)
39
+ ruby-progressbar (~> 1.7)
40
+ unicode-display_width (>= 2.4.0, < 3.0)
41
+ rubocop-ast (1.30.0)
42
+ parser (>= 3.2.1.0)
43
+ ruby-progressbar (1.13.0)
44
+ unicode-display_width (2.5.0)
45
+ uri (0.13.0)
46
+
47
+ PLATFORMS
48
+ arm64-darwin-22
49
+
50
+ DEPENDENCIES
51
+ moco-ruby!
52
+ rake (~> 13.0)
53
+ rubocop (~> 1.21)
54
+
55
+ BUNDLED WITH
56
+ 2.4.1
data/LICENSE ADDED
@@ -0,0 +1,201 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright [yyyy] [name of copyright owner]
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
data/README.md ADDED
@@ -0,0 +1,101 @@
1
+ # moco-ruby
2
+
3
+ A Ruby Gem to interact with the [MOCO API](https://hundertzehn.github.io/mocoapp-api-docs/).
4
+
5
+ ## Installation
6
+
7
+ Install the gem and add to the application's Gemfile by executing:
8
+
9
+ $ bundle add moco-ruby
10
+
11
+ If bundler is not being used to manage dependencies, install the gem by executing:
12
+
13
+ $ gem install moco-ruby
14
+
15
+ ## Usage
16
+
17
+ ### MOCO::API
18
+
19
+ ```ruby
20
+ moco = MOCO::API.new(subdomain, api_key)
21
+ assigned_projects = moco.get_assigned_projects(active: 'true')
22
+ assigned_projects.each do |project|
23
+ puts "Project \##{project.id} #{project.name} for customer #{project.customer.name}"
24
+ project.tasks.each do |task|
25
+ puts "- Task #{task.name} is #{task.billable ? 'billable' : 'not billable'}"
26
+ end
27
+ end
28
+ ```
29
+
30
+ ### MOCO::Entities
31
+
32
+ The following entities are currently defined:
33
+
34
+ - Project (:id, :active, :name, :customer, :tasks)
35
+ - Task (:id, :active, :name, :project_id, :billable)
36
+ - Activity (:id, :active, :date, :description, :project, :task, :seconds, :hours, :billable, :billed, :user, :customer, :tag)
37
+ - Customer (:id, :name)
38
+ - User (:id, :firstname, :lastname)
39
+
40
+ The entities implement comparison, hash and JSON conversion.
41
+
42
+ ### MOCO::Sync
43
+
44
+ Intelligently matches and syncs data from one MOCO instance to another.
45
+ Currently supports activities (time entries) only.
46
+ See `sync_activity.rb` for a more detailed example.
47
+
48
+ ```ruby
49
+ source_api = MOCO::API.new(source_instance, source_api_key)
50
+ target_api = MOCO::API.new(target_instance, target_api_key)
51
+
52
+ syncer = MOCO::Sync.new(
53
+ source_api,
54
+ target_api,
55
+ project_match_threshold: options[:match_project_threshold],
56
+ task_match_threshold: options[:match_task_threshold],
57
+ filters: {
58
+ source: options.slice(:from, :to, :project_id, :company_id, :term),
59
+ target: options.slice(:from, :to)
60
+ },
61
+ dry_run: options[:dry_run]
62
+ )
63
+ ```
64
+
65
+ ## Utilities
66
+
67
+ Utilities can use `config.yml` to fetch instance data and other configuration. For format, see `config.yml.sample`.
68
+
69
+ ### mocurl
70
+
71
+ Run an API request against a MOCO instance and return the result nicely formatted.
72
+ Use config.yml or specify api key with `-a`.
73
+
74
+ ```
75
+ Usage: mocurl.rb [options] url
76
+ mocurl.rb [options] subdomain path
77
+ -X, --method METHOD Set HTTP method to use
78
+ -d, --data DATA Data to send to server, JSON format
79
+ -a, --api-key API_KEY Manually specify MOCO API key
80
+ -n, --no-format Disable JSON pretty-printing
81
+ -v, --verbose Show additional request and response information
82
+ -h, --help Show this message
83
+ ```
84
+
85
+ ### sync_activity
86
+
87
+ Sync activity data (time entries) from one instance to another, fuzzy matching projects and tasks.
88
+ It is highly recommended to use filter options (`--from`, `--to`) and to use `--dry-run` first to check the matching performance.
89
+
90
+ ```
91
+ Usage: sync_activity.rb [options] source target
92
+ -f, --from DATE Start date (YYYY-MM-DD)
93
+ -t, --to DATE End date (YYYY-MM-DD)
94
+ -p, --project PROJECT_ID Project ID to filter by
95
+ -c, --company COMPANY_ID Company ID to filter by
96
+ -g, --term TERM Term to filter for
97
+ -n, --dry-run Match only, but do not edit data
98
+ --match-project-threshold VALUE
99
+ Project matching threshold (0.0 - 1.0), default 0.8
100
+ --match-task-threshold VALUE Task matching threshold (0.0 - 1.0), default 0.45
101
+ ```
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rubocop/rake_task"
5
+
6
+ RuboCop::RakeTask.new
7
+
8
+ task default: :rubocop
data/config.yml.sample ADDED
@@ -0,0 +1,9 @@
1
+ match_thresholds:
2
+ project: 0.80
3
+ task: 0.45
4
+
5
+ instances:
6
+ "my-own-moco-subdomain":
7
+ api_key: 1234abcdef
8
+ "some-target-subdomain":
9
+ api_key: 1234abcdef
data/lib/moco/api.rb ADDED
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require_relative "entities"
5
+
6
+ module MOCO
7
+ # MOCO::API abstracts access to the MOCO API and its entities
8
+ class API
9
+ def initialize(subdomain, api_key)
10
+ @subdomain = subdomain
11
+ @api_key = api_key
12
+ @conn = Faraday.new do |f|
13
+ f.request :json
14
+ f.response :json
15
+ f.request :authorization, "Token", "token=#{@api_key}" if @api_key
16
+ f.url_prefix = "https://#{@subdomain}.mocoapp.com/api/v1"
17
+ end
18
+ end
19
+
20
+ %w[get post put patch delete].each do |method|
21
+ define_method(method) do |path, *args|
22
+ @conn.send(method, path, *args)
23
+ end
24
+ end
25
+
26
+ def get_projects(**args)
27
+ response = @conn.get("projects?#{Faraday::Utils.build_query(args)}")
28
+ parse_projects_response(response.body)
29
+ end
30
+
31
+ def get_assigned_projects(**args)
32
+ response = @conn.get("projects/assigned?#{Faraday::Utils.build_query(args)}")
33
+ parse_projects_response(response.body)
34
+ end
35
+
36
+ def get_activities(filters = {})
37
+ response = @conn.get("activities?#{Faraday::Utils.build_query(filters)}")
38
+ parse_activities_response(response.body)
39
+ end
40
+
41
+ def create_activity(activity)
42
+ api_entity = activity.to_h.except(:id, :project, :user, :customer).tap do |h|
43
+ h[:project_id] = activity.project.id
44
+ h[:task_id] = activity.task.id
45
+ end
46
+ @conn.post("activities", api_entity)
47
+ end
48
+
49
+ def update_activity(activity)
50
+ api_entity = activity.to_h.except(:project, :user, :customer).tap do |h|
51
+ h[:project_id] = activity.project.id
52
+ h[:task_id] = activity.task.id
53
+ end
54
+ @conn.put("activities/#{activity.id}", api_entity)
55
+ end
56
+
57
+ private
58
+
59
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
60
+ def parse_projects_response(data)
61
+ data.map do |project_data|
62
+ Project.new.tap do |project|
63
+ project.id = project_data["id"]
64
+ project.name = project_data["name"]
65
+ project.customer = parse_customer_reference(project_data["customer"])
66
+ project.tasks = project_data["tasks"].map do |task_data|
67
+ Task.new.tap do |task|
68
+ task.id = task_data["id"]
69
+ task.name = task_data["name"]
70
+ task.project_id = task_data["project_id"]
71
+ task.billable = task_data["billable"]
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
77
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
78
+
79
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
80
+ def parse_activities_response(data)
81
+ data.map do |activity_data|
82
+ Activity.new.tap do |activity|
83
+ activity.id = activity_data["id"]
84
+ activity.date = activity_data["date"]
85
+ activity.description = activity_data["description"]
86
+ activity.user = parse_user_reference(activity_data["user"])
87
+ activity.customer = parse_customer_reference(activity_data["customer"])
88
+ activity.project = parse_project_reference(activity_data["project"])
89
+ activity.task = parse_task_reference(activity_data["task"])
90
+ activity.hours = activity_data["hours"]
91
+ activity.seconds = activity_data["seconds"]
92
+ activity.billable = activity_data["billable"]
93
+ activity.billed = activity_data["billed"]
94
+ activity.tag = activity_data["tag"]
95
+ end
96
+ end
97
+ end
98
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
99
+
100
+ def parse_project_reference(project_data)
101
+ Project.new.tap do |project|
102
+ project.id = project_data["id"]
103
+ project.name = project_data["name"]
104
+ end
105
+ end
106
+
107
+ def parse_task_reference(task_data)
108
+ Task.new.tap do |task|
109
+ task.id = task_data["id"]
110
+ task.name = task_data["name"]
111
+ end
112
+ end
113
+
114
+ def parse_user_reference(user_data)
115
+ User.new.tap do |user|
116
+ user.id = user_data["id"]
117
+ user.firstname = user_data["firstname"]
118
+ user.lastname = user_data["lastname"]
119
+ end
120
+ end
121
+
122
+ def parse_customer_reference(customer_data)
123
+ Customer.new.tap do |customer|
124
+ customer.id = customer_data["id"]
125
+ customer.name = customer_data["name"]
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MOCO
4
+ # Base entity class others inherit from, providing comparison, to_h, to_json
5
+ class BaseEntity
6
+ def eql?(other)
7
+ return false unless other.is_a? self.class
8
+
9
+ id == other.id
10
+ end
11
+
12
+ def hash
13
+ id.hash
14
+ end
15
+
16
+ def ==(other)
17
+ id == other.id
18
+ end
19
+
20
+ def to_h
21
+ hash = {}
22
+ instance_variables.each do |var|
23
+ key = var.to_s.delete_prefix("@")
24
+ hash[key.to_sym] = instance_variable_get(var)
25
+ end
26
+ hash
27
+ end
28
+
29
+ # rubocop:disable Metrics/MethodLength
30
+ def to_json(*arg)
31
+ to_h do |k, v|
32
+ if v.is_a? Hash
33
+ if v.key?(:id) && !v[:id].nil?
34
+ ["#{k}_id", v[:id]]
35
+ else
36
+ [k, v.except(:id)]
37
+ end
38
+ else
39
+ [k, v]
40
+ end
41
+ end.to_h.to_json(arg)
42
+ end
43
+ end
44
+ # rubocop:enable Metrics/MethodLength
45
+
46
+ # https://hundertzehn.github.io/mocoapp-api-docs/sections/projects.html
47
+ class Project < BaseEntity
48
+ attr_accessor :id, :active, :name, :customer, :tasks
49
+
50
+ def to_s
51
+ [customer&.name, name].join(" / ")
52
+ end
53
+ end
54
+
55
+ # https://hundertzehn.github.io/mocoapp-api-docs/sections/project_tasks.html
56
+ class Task < BaseEntity
57
+ attr_accessor :id, :active, :name, :project_id, :billable
58
+
59
+ def to_s
60
+ name
61
+ end
62
+ end
63
+
64
+ # https://hundertzehn.github.io/mocoapp-api-docs/sections/activities.html
65
+ class Activity < BaseEntity
66
+ attr_accessor :id, :active, :date, :description, :project, :task, :seconds, :hours, :billable, :billed, :user,
67
+ :customer, :tag
68
+
69
+ def to_s
70
+ "#{date} - #{hours}h (#{seconds}s) - #{project&.name} - #{task&.name}#{description.empty? ? "" : " (#{description})"} " \
71
+ "(#{%i[billable billed].map { |x| (send(x) ? "" : "not ") + x.to_s }.join(", ")})"
72
+ end
73
+ end
74
+
75
+ # https://hundertzehn.github.io/mocoapp-api-docs/sections/companies.html
76
+ class Customer < BaseEntity
77
+ attr_accessor :id, :name
78
+ end
79
+
80
+ # https://hundertzehn.github.io/mocoapp-api-docs/sections/users.html
81
+ class User < BaseEntity
82
+ attr_accessor :id, :firstname, :lastname
83
+ end
84
+ end
data/lib/moco/sync.rb ADDED
@@ -0,0 +1,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fuzzy_match"
4
+ require_relative "api"
5
+
6
+ module MOCO
7
+ # Match and map projects and tasks between MOCO instances and sync activities
8
+ class Sync
9
+ attr_reader :project_mapping, :task_mapping, :source_projects, :target_projects
10
+ attr_accessor :project_match_threshold, :task_match_threshold, :dry_run
11
+
12
+ def initialize(source_instance_api, target_instance_api, **args)
13
+ @source_api = source_instance_api
14
+ @target_api = target_instance_api
15
+ @project_match_threshold = args.fetch(:project_match_threshold, 0.8)
16
+ @task_match_threshold = args.fetch(:task_match_threshold, 0.45)
17
+ @filters = args.fetch(:filters, {})
18
+ @dry_run = args.fetch(:dry_run, false)
19
+
20
+ @project_mapping = {}
21
+ @task_mapping = {}
22
+
23
+ fetch_assigned_projects
24
+ build_initial_mappings
25
+ end
26
+
27
+ # rubocop:todo Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
28
+ def sync(&callbacks)
29
+ results = []
30
+
31
+ source_activities_r = @source_api.get_activities(@filters.fetch(:source, {}))
32
+ target_activities_r = @target_api.get_activities(@filters.fetch(:target, {}))
33
+
34
+ source_activities_grouped = source_activities_r.group_by(&:date).transform_values do |activities|
35
+ activities.group_by(&:project)
36
+ end
37
+ target_activities_grouped = target_activities_r.group_by(&:date).transform_values do |activities|
38
+ activities.group_by(&:project)
39
+ end
40
+
41
+ source_activities_grouped.each do |date, activities_by_project|
42
+ activities_by_project.each do |project, source_activities|
43
+ target_activities = target_activities_grouped.fetch(date, {}).fetch(@project_mapping[project.id], [])
44
+ next if source_activities.empty? || target_activities.empty?
45
+
46
+ matches = calculate_matches(source_activities, target_activities)
47
+ matches.sort_by! { |match| -match[:score] }
48
+
49
+ used_source_activities = []
50
+ used_target_activities = []
51
+
52
+ matches.each do |match|
53
+ source_activity, target_activity = match[:activity]
54
+ score = match[:score]
55
+
56
+ next if used_source_activities.include?(source_activity) || used_target_activities.include?(target_activity)
57
+
58
+ best_score = score
59
+ best_match = target_activity
60
+ expected_target_activity = get_expected_target_activity(source_activity)
61
+
62
+ case best_score
63
+ when 100
64
+ # 100 - perfect match found, nothing needs doing
65
+ callbacks&.call(:equal, source_activity, expected_target_activity)
66
+ when 60...100
67
+ # >=60 <100 - match with some differences
68
+ expected_target_activity.to_h.except(:id, :user, :customer).each do |k, v|
69
+ best_match.send("#{k}=", v)
70
+ end
71
+ callbacks&.call(:update, source_activity, best_match)
72
+ unless @dry_run
73
+ results << @target_api.update_activity(best_match)
74
+ callbacks&.call(:updated, source_activity, best_match, results.last)
75
+ end
76
+ when 0...60
77
+ # <60 - no good match found, create new entry
78
+ callbacks&.call(:create, source_activity, expected_target_activity)
79
+ unless @dry_run
80
+ results << @target_api.create_activity(expected_target_activity)
81
+ callbacks&.call(:created, source_activity, best_match, results.last)
82
+ end
83
+ end
84
+
85
+ used_source_activities << source_activity
86
+ used_target_activities << target_activity
87
+ end
88
+ end
89
+ end
90
+ results
91
+ end
92
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
93
+
94
+ private
95
+
96
+ def get_expected_target_activity(source_activity)
97
+ source_activity.dup.tap do |a|
98
+ a.task = @task_mapping[source_activity.task.id]
99
+ a.project = @project_mapping[source_activity.project.id]
100
+ end
101
+ end
102
+
103
+ def calculate_matches(source_activities, target_activities)
104
+ matches = []
105
+ source_activities.each do |source_activity|
106
+ target_activities.each do |target_activity|
107
+ score = score_activity_match(get_expected_target_activity(source_activity), target_activity)
108
+ matches << { activity: [source_activity, target_activity], score: score }
109
+ end
110
+ end
111
+ matches
112
+ end
113
+
114
+ def clamped_factored_diff_score(a, b, cmin = 0.0, cmax = 7.0, factor = 0.5)
115
+ difference = (a - b).abs.clamp(cmin, cmax)
116
+ normalized_difference = difference / cmax
117
+ sublinear_factor = normalized_difference**factor
118
+ score = 1 - sublinear_factor
119
+ [0.0, score].max
120
+ end
121
+
122
+ # rubocop:disable Metrics/AbcSize
123
+ def score_activity_match(a, b)
124
+ return 0 if a.project != b.project
125
+
126
+ score = 0
127
+ # (mapped) task is the same as the source task
128
+ score += 20 if a.task == b.task
129
+ # description fuzzy match score (0.0 .. 1.0)
130
+ _, description_match_score = FuzzyMatch.new([a.description]).find_with_score(b.description)
131
+ score += (description_match_score * 40.0).to_i if description_match_score
132
+ # differences in time tracked are weighted by sqrt of diff clamped to 7h
133
+ # i.e. smaller differences are worth higher scores; 1.75h diff = 0.5 score * 40
134
+ score += (clamped_factored_diff_score(a.hours, b.hours) * 40.0).to_i
135
+
136
+ score
137
+ end
138
+ # rubocop:enable Metrics/AbcSize
139
+
140
+ def fetch_assigned_projects
141
+ @source_projects = @source_api.get_assigned_projects(**@filters.fetch(:source, {}).merge(active: "true"))
142
+ @target_projects = @target_api.get_assigned_projects(**@filters.fetch(:target, {}).merge(active: "true"))
143
+ end
144
+
145
+ def build_initial_mappings
146
+ @target_projects.each do |target_project|
147
+ source_project = match_project(target_project)
148
+ next unless source_project
149
+
150
+ @project_mapping[source_project.id] = target_project
151
+ target_project.tasks.each do |target_task|
152
+ source_task = match_task(target_task, source_project)
153
+ @task_mapping[source_task.id] = target_task if source_task
154
+ end
155
+ end
156
+ end
157
+
158
+ def match_project(target_project)
159
+ matcher = FuzzyMatch.new(@source_projects, read: :name)
160
+ matcher.find(target_project.name, threshold: @project_match_threshold)
161
+ end
162
+
163
+ def match_task(target_task, source_project)
164
+ matcher = FuzzyMatch.new(source_project.tasks, read: :name)
165
+ matcher.find(target_task.name, threshold: @task_match_threshold)
166
+ end
167
+ end
168
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MOCO
4
+ VERSION = "0.1.1"
5
+ end
data/lib/moco.rb ADDED
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "moco/version"
4
+ require_relative "moco/entities"
5
+ require_relative "moco/api"
6
+ require_relative "moco/sync"
7
+
8
+ module MOCO
9
+ class Error < StandardError; end
10
+ end
data/mocurl.rb ADDED
@@ -0,0 +1,99 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "optparse"
5
+ require "yaml"
6
+ require "json"
7
+ require_relative "lib/moco"
8
+
9
+ options = { method: "GET", data: {}, api_key: nil, no_format: false, verbose: false }
10
+ OptionParser.new do |opts|
11
+ opts.banner = "Usage: #{$PROGRAM_NAME} [options] url\n " \
12
+ "#{$PROGRAM_NAME} [options] subdomain path"
13
+
14
+ opts.on("-X", "--method METHOD", "Set HTTP method to use") do |method|
15
+ options[:method] = method.upcase
16
+ end
17
+
18
+ opts.on("-d", "--data DATA", "Data to send to server, JSON format") do |data|
19
+ options[:data] = JSON.parse(data)
20
+ end
21
+
22
+ opts.on("-a", "--api-key API_KEY", "Manually specify MOCO API key") do |key|
23
+ options[:api_key] = key
24
+ end
25
+
26
+ opts.on("-n", "--no-format", "Disable JSON pretty-printing") do
27
+ options[:no_format] = true
28
+ end
29
+
30
+ opts.on("-v", "--verbose", "Show additional request and response information") do
31
+ options[:verbose] = true
32
+ end
33
+
34
+ opts.on_tail("-h", "--help", "Show this message") do
35
+ puts opts
36
+ exit
37
+ end
38
+ end.parse!
39
+
40
+ def extract_subdomain(url)
41
+ url.match(%r{https?://([^.]+)\.mocoapp\.com})[1]
42
+ end
43
+
44
+ # Ensure we have a URL
45
+ url = ARGV.shift
46
+ if url.nil?
47
+ warn "Error: URL is required"
48
+ exit 1
49
+ end
50
+
51
+ if ARGV.empty?
52
+ subdomain = extract_subdomain(url)
53
+ else
54
+ subdomain = url
55
+ path = ARGV.shift
56
+ url = "https://#{subdomain}.mocoapp.com/api/v1/#{path.gsub(%r{\A/}, "")}"
57
+ end
58
+
59
+ # Load default API key from config
60
+ config = YAML.load_file("config.yml")
61
+ options[:api_key] ||= config["instances"].fetch(subdomain, nil)&.fetch("api_key", nil)
62
+
63
+ warn "Error: No API key found for `#{subdomain}' and none given, continuing without" if options[:api_key].nil?
64
+
65
+ api = MOCO::API.new(subdomain, options[:api_key])
66
+
67
+ case options[:method]
68
+ when "GET"
69
+ result = api.get(url)
70
+ when "DELETE"
71
+ result = api.delete(url)
72
+ when "POST"
73
+ result = api.post(url, options[:data])
74
+ when "PUT"
75
+ result = api.put(url, options[:data])
76
+ when "PATCH"
77
+ result = api.patch(url, options[:data])
78
+ else
79
+ puts "Error: Invalid HTTP Method: #{options[:method]}"
80
+ exit 1
81
+ end
82
+
83
+ if options[:verbose]
84
+ puts "> #{options[:method]} #{result.env.url}"
85
+ puts(result.env.request_headers.map do |k, v|
86
+ "> #{k}: #{k == "Authorization" ? "#{v[0...16]}<REDACTED>#{v[-4..]}" : v}"
87
+ end)
88
+ puts ">"
89
+ puts result.env.request_body.split.map { |l| "> #{l}" }.join if result.env.request_body
90
+ puts "---"
91
+ puts "< #{result.status} #{result.reason_phrase}"
92
+ puts(result.headers.map { |k, v| "< #{k}: #{v}" })
93
+ puts ""
94
+ end
95
+ if options[:no_format]
96
+ puts result.body.to_json
97
+ else
98
+ puts JSON.pretty_generate(result.body)
99
+ end
data/sync_activity.rb ADDED
@@ -0,0 +1,111 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "optparse"
5
+ require "yaml"
6
+ require_relative "lib/moco"
7
+
8
+ options = {
9
+ from: nil,
10
+ to: nil,
11
+ project: nil,
12
+ match_project_threshold: 0.8,
13
+ match_task_threshold: 0.45
14
+ }
15
+
16
+ OptionParser.new do |opts|
17
+ opts.banner = "Usage: #{$PROGRAM_NAME} [options] source target"
18
+
19
+ opts.on("-f", "--from DATE", "Start date (YYYY-MM-DD)") do |date|
20
+ options[:from] = date
21
+ end
22
+
23
+ opts.on("-t", "--to DATE", "End date (YYYY-MM-DD)") do |date|
24
+ options[:to] = date
25
+ end
26
+
27
+ opts.on("-p", "--project PROJECT_ID", "Project ID to filter by") do |project_id|
28
+ options[:project_id] = project_id
29
+ end
30
+
31
+ opts.on("-c", "--company COMPANY_ID", "Company ID to filter by") do |company_id|
32
+ options[:company_id] = company_id
33
+ end
34
+
35
+ opts.on("-g", "--term TERM", "Term to filter for") do |term|
36
+ options[:term] = term
37
+ end
38
+
39
+ opts.on("-n", "--dry-run", "Match only, but do not edit data") do
40
+ options[:dry_run] = true
41
+ end
42
+
43
+ opts.on("--match-project-threshold VALUE", Float, "Project matching threshold (0.0 - 1.0), default 0.8") do |val|
44
+ options[:match_project_threshold] = val
45
+ end
46
+
47
+ opts.on("--match-task-threshold VALUE", Float, "Task matching threshold (0.0 - 1.0), default 0.45") do |val|
48
+ options[:match_task_threshold] = val
49
+ end
50
+ end.parse!
51
+
52
+ source_instance = ARGV.shift
53
+ target_instance = ARGV.shift
54
+ if source_instance.nil?
55
+ warn "Source instance is required"
56
+ exit 1
57
+ end
58
+ if target_instance.nil?
59
+ warn "Target instance is required"
60
+ exit 1
61
+ end
62
+
63
+ config = YAML.load_file("config.yml")
64
+ source_config = config["instances"].fetch(source_instance, nil)
65
+ target_config = config["instances"].fetch(target_instance, nil)
66
+
67
+ source_api = MOCO::API.new(source_instance, source_config["api_key"])
68
+ target_api = MOCO::API.new(target_instance, target_config["api_key"])
69
+
70
+ syncer = MOCO::Sync.new(
71
+ source_api,
72
+ target_api,
73
+ project_match_threshold: options[:match_project_threshold],
74
+ task_match_threshold: options[:match_task_threshold],
75
+ filters: {
76
+ source: options.slice(:from, :to, :project_id, :company_id, :term),
77
+ target: options.slice(:from, :to)
78
+ },
79
+ dry_run: options[:dry_run]
80
+ )
81
+
82
+ syncer.source_projects.each do |project|
83
+ if syncer.project_mapping[project.id]
84
+ puts "✅ Project #{project} --> #{syncer.project_mapping[project.id]}"
85
+ project.tasks.each do |task|
86
+ if syncer.task_mapping[task.id]
87
+ puts " ✅ Task #{task} --> #{syncer.task_mapping[task.id]}"
88
+ else
89
+ puts " ❌ Task #{task} not mapped"
90
+ end
91
+ end
92
+ else
93
+ puts "❌ Project #{project} not mapped"
94
+ end
95
+ puts ""
96
+ end
97
+
98
+ syncer.sync do |event, source, target|
99
+ case event
100
+ when :equal
101
+ puts "👀 EXISTS\n #{source}\n == #{target}"
102
+ when :update
103
+ puts "📝 UPDATE\n #{source}\n -> #{target}"
104
+ when :updated
105
+ puts "UPDATED"
106
+ when :create
107
+ puts "🆕 CREATE\n #{target}"
108
+ when :created
109
+ puts "CREATED"
110
+ end
111
+ end
metadata ADDED
@@ -0,0 +1,92 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: moco-ruby
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.1
5
+ platform: ruby
6
+ authors:
7
+ - Teal Bauer
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2024-02-27 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: faraday
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: 2.9.0
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: 2.9.0
27
+ - !ruby/object:Gem::Dependency
28
+ name: fuzzy_match
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: 2.1.0
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: 2.1.0
41
+ description:
42
+ email:
43
+ - rubygems@teal.is
44
+ executables: []
45
+ extensions: []
46
+ extra_rdoc_files: []
47
+ files:
48
+ - ".rubocop.yml"
49
+ - ".tool-versions"
50
+ - ".vscode/launch.json"
51
+ - CHANGELOG.md
52
+ - Gemfile
53
+ - Gemfile.lock
54
+ - LICENSE
55
+ - README.md
56
+ - Rakefile
57
+ - config.yml.sample
58
+ - lib/moco.rb
59
+ - lib/moco/api.rb
60
+ - lib/moco/entities.rb
61
+ - lib/moco/sync.rb
62
+ - lib/moco/version.rb
63
+ - mocurl.rb
64
+ - sync_activity.rb
65
+ homepage: https://github.com/moeffju/moco-ruby
66
+ licenses:
67
+ - Apache-2.0
68
+ metadata:
69
+ homepage_uri: https://github.com/moeffju/moco-ruby
70
+ source_code_uri: https://github.com/moeffju/moco-ruby
71
+ changelog_uri: https://github.com/moeffju/moco-ruby/blob/main/CHANGELOG.md
72
+ rubygems_mfa_required: 'true'
73
+ post_install_message:
74
+ rdoc_options: []
75
+ require_paths:
76
+ - lib
77
+ required_ruby_version: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: 2.6.0
82
+ required_rubygems_version: !ruby/object:Gem::Requirement
83
+ requirements:
84
+ - - ">="
85
+ - !ruby/object:Gem::Version
86
+ version: '0'
87
+ requirements: []
88
+ rubygems_version: 3.4.1
89
+ signing_key:
90
+ specification_version: 4
91
+ summary: A Ruby Gem to interact with the MOCO (mocoapp.com) API.
92
+ test_files: []