zensana 1.0.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: ea308a2a19d3adbe4fbd80bca3ddc3a09042668b
4
+ data.tar.gz: fa62e437c65bd88ebf8a2ec4ff0d716ae04813f8
5
+ SHA512:
6
+ metadata.gz: d0383bcc73118353f5c0bacf11beb5fd10ce72bd6b663ceafddf5ca521b5a6387d80aec6b221a6646f5104a93ec1bc51fe9e7db65428b71def38b6c11a8132a2
7
+ data.tar.gz: 1b81528f9917e9f6982e81355c0fa595711625c26fe286f1ec209920b9b0c5ffef6772d5cd9e890ca8d836e60cbe30e83f0d85939e66e5732175103f76140ebb
data/.gitignore ADDED
@@ -0,0 +1,9 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /Gemfile.lock
4
+ /_yardoc/
5
+ /coverage/
6
+ /doc/
7
+ /pkg/
8
+ /spec/reports/
9
+ /tmp/
data/.rspec ADDED
@@ -0,0 +1,2 @@
1
+ --format documentation
2
+ --color
data/.travis.yml ADDED
@@ -0,0 +1,3 @@
1
+ language: ruby
2
+ rvm:
3
+ - 2.2.1
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in zensana.gemspec
4
+ gemspec
data/Guardfile ADDED
@@ -0,0 +1,55 @@
1
+ # A sample Guardfile
2
+ # More info at https://github.com/guard/guard#readme
3
+
4
+ # Note: The cmd option is now required due to the increasing number of ways
5
+ # rspec may be run, below are examples of the most common uses.
6
+ # * bundler: 'bundle exec rspec'
7
+ # * bundler binstubs: 'bin/rspec'
8
+ # * spring: 'bin/rspec' (This will use spring if running and you have
9
+ # installed the spring binstubs per the docs)
10
+ # * zeus: 'zeus rspec' (requires the server to be started separately)
11
+ # * 'just' rspec: 'rspec'
12
+
13
+ guard :rspec, cmd: "bundle exec rspec" do
14
+ require "guard/rspec/dsl"
15
+ dsl = Guard::RSpec::Dsl.new(self)
16
+
17
+ # Feel free to open issues for suggestions and improvements
18
+
19
+ # RSpec files
20
+ rspec = dsl.rspec
21
+ watch(rspec.spec_helper) { rspec.spec_dir }
22
+ watch(rspec.spec_support) { rspec.spec_dir }
23
+ watch(rspec.spec_files)
24
+
25
+ # Ruby files
26
+ ruby = dsl.ruby
27
+ dsl.watch_spec_files_for(ruby.lib_files)
28
+
29
+ # Rails files
30
+ rails = dsl.rails(view_extensions: %w(erb haml slim))
31
+ dsl.watch_spec_files_for(rails.app_files)
32
+ dsl.watch_spec_files_for(rails.views)
33
+
34
+ watch(rails.controllers) do |m|
35
+ [
36
+ rspec.spec.("routing/#{m[1]}_routing"),
37
+ rspec.spec.("controllers/#{m[1]}_controller"),
38
+ rspec.spec.("acceptance/#{m[1]}")
39
+ ]
40
+ end
41
+
42
+ # Rails config changes
43
+ watch(rails.spec_helper) { rspec.spec_dir }
44
+ watch(rails.routes) { "#{rspec.spec_dir}/routing" }
45
+ watch(rails.app_controller) { "#{rspec.spec_dir}/controllers" }
46
+
47
+ # Capybara features specs
48
+ watch(rails.view_dirs) { |m| rspec.spec.("features/#{m[1]}") }
49
+
50
+ # Turnip features and steps
51
+ watch(%r{^spec/acceptance/(.+)\.feature$})
52
+ watch(%r{^spec/acceptance/steps/(.+)_steps\.rb$}) do |m|
53
+ Dir[File.join("**/#{m[1]}.feature")][0] || "spec/acceptance"
54
+ end
55
+ end
data/LICENCE.txt ADDED
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2015 Warren Bain
2
+
3
+ MIT License
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,165 @@
1
+ # Zensana
2
+
3
+ This gem provides access to the Asana API and ZenDesk Ticket Import API
4
+ for the purpose of importing tasks from Asana Projects into ZenDesk
5
+ tickets.
6
+
7
+ ## Installation
8
+
9
+ Add this line to your application's Gemfile:
10
+
11
+ ```ruby
12
+ gem 'zensana'
13
+ ```
14
+
15
+ And then execute:
16
+
17
+ $ bundle
18
+
19
+ Or install it yourself as:
20
+
21
+ $ gem install zensana
22
+
23
+ ## Usage
24
+
25
+ I had a specific use-case in developing this: to get off Asana as a
26
+ support ticketing system and onto ZenDesk. So there aren't many tests
27
+ (I know, I know) and there are only a few commands available in the cli.
28
+
29
+ ### CLI
30
+
31
+ This uses Thor so follows the general pattern of
32
+
33
+ $ zensana COMMAND SUBCOMMAND OPTIONS
34
+
35
+ The help is pretty self-explanatory around the options so just try that
36
+
37
+ $ zensana help
38
+ Commands:
39
+ zensana help [COMMAND] # Describe available commands or one specific command
40
+ zensana project SUBCOMMAND # perform actions on Asana projects
41
+
42
+ #### project command
43
+
44
+ The primary use for zensana is to convert an Asana project's tasks into
45
+ ZenDesk tickets. The `convert` subcommand has quite a few options to
46
+ control what gets converted.
47
+
48
+ $ zensana project help convert
49
+ Usage:
50
+ zensana convert PROJECT
51
+
52
+ Options:
53
+ -a, [--attachments], [--no-attachments] # download and upload any attachments
54
+ # Default: true
55
+ -c, [--completed], [--no-completed] # include tasks that are completed
56
+ -u, [--default-user=DEFAULT_USER] # set a default user to assign to tickets
57
+ -s, [--stories], [--no-stories] # import stories as comments
58
+ # Default: true
59
+ -v, [--verified], [--no-verified] # `false` will send email to zendesk users created
60
+ # Default: true
61
+
62
+ Convert PROJECT tasks to ZenDesk tickets (exact ID or NAME required)
63
+
64
+ #### idempotentcy
65
+
66
+ To ensure robustness of the conversion, especially given that internet
67
+ connection issues may interrupt it, the project conversion is idempotent
68
+ and can be restarted as many times as necessary. In particular
69
+
70
+ * tasks that have already been successfully created will not be
71
+ recreated as tickets (the Asana task_id is stored as the Zendesk
72
+ ticket external_id)
73
+ * attachments will only be downloaded if they do not exist in the
74
+ download directory
75
+
76
+ ### Classes
77
+
78
+ You can also directly access classes which model Asana data objects.
79
+
80
+ ```ruby
81
+ require 'zensana'
82
+
83
+ my_project = Zensana::Asana::Project.new('My awesome Asana Project')
84
+
85
+ my_project.full_tasks.each do |task|
86
+ puts "Task #{task.name} has tags: #{task.tags}"
87
+ end
88
+ ```
89
+
90
+ Check ../commands/project.rb for examples in action.
91
+
92
+ #### Asana
93
+
94
+ There is support for reading:
95
+ * user
96
+ * project
97
+ * task
98
+ * attachment - downloading
99
+
100
+ #### ZenDesk
101
+
102
+ There is support for accessing and updating:
103
+ * user
104
+ * ticket - via the Ticket Import API only
105
+ * comment
106
+ * attachment - uploading
107
+
108
+ ## Authenticating
109
+
110
+ ### Asana
111
+
112
+ Asana users can connect to the API using their username / password or
113
+ by creating an API key. The following environment vars are required:
114
+
115
+ ```ruby
116
+ ASANA_API_KEY
117
+ or
118
+ ASANA_USERNAME
119
+ ASANA_PASSWORD
120
+ ```
121
+
122
+ ### ZenDesk
123
+
124
+ ZenDesk by default requires username / password to connect to the API,
125
+ and the endpoint is defined by the organisation domain name. The following
126
+ environment vars are required:
127
+
128
+ ```ruby
129
+ ZENDESK_USERNAME
130
+ ZENDESK_PASSWORD
131
+ ZENDESK_DOMAIN
132
+ ```
133
+
134
+ ## Contributing
135
+
136
+ 1. Fork it ( https://github.com/thoughtcroft/zensana/fork )
137
+ 2. Create your feature branch (`git checkout -b my-new-feature`)
138
+ 3. Commit your changes (`git commit -am 'Add some feature'`)
139
+ 4. Push to the branch (`git push origin my-new-feature`)
140
+ 5. Create a new Pull Request
141
+
142
+ # License
143
+
144
+ ### This code is free to use under the terms of the MIT license.
145
+
146
+ Copyright (c) 2015 Warren Bain
147
+
148
+ Permission is hereby granted, free of charge, to any person obtaining
149
+ a copy of this software and associated documentation files (the
150
+ "Software"), to deal in the Software without restriction, including
151
+ without limitation the rights to use, copy, modify, merge, publish,
152
+ distribute, sublicense, and/or sell copies of the Software, and to
153
+ permit persons to whom the Software is furnished to do so, subject to
154
+ the following conditions:
155
+
156
+ The above copyright notice and this permission notice shall be
157
+ included in all copies or substantial portions of the Software.
158
+
159
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
160
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
161
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
162
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
163
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
164
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
165
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/Rakefile ADDED
@@ -0,0 +1,2 @@
1
+ require "bundler/gem_tasks"
2
+
data/bin/zensana ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ $:.unshift File.expand_path("../../lib", __FILE__)
4
+
5
+ require "zensana"
6
+
7
+ Zensana::Cli.start
@@ -0,0 +1,8 @@
1
+ module Zensana
2
+ class Cli < Zensana::Command
3
+
4
+ desc 'project SUBCOMMAND', 'perform actions on Asana projects'
5
+ subcommand 'project', Project
6
+
7
+ end
8
+ end
@@ -0,0 +1,7 @@
1
+ require 'thor'
2
+
3
+ module Zensana
4
+ class Command < Thor
5
+ include Thor::Actions
6
+ end
7
+ end
@@ -0,0 +1,293 @@
1
+ module Zensana
2
+ class Command::Project < Zensana::Command
3
+
4
+ desc 'find PROJECT', 'List projects that match PROJECT (by ID or NAME, regexp accepted)'
5
+ def find(name)
6
+ puts Zensana::Asana::Project.search(name).collect { |p| p['name'] }.sort
7
+ end
8
+
9
+ desc 'show PROJECT', 'Display details of PROJECT (choosing from list matching ID or NAME, regexp accepted)'
10
+ option :tasks, type: 'boolean', aliases: '-t', default: true, desc: 'display project task summary'
11
+ def show(project)
12
+ candidates = Zensana::Asana::Project.search(project)
13
+
14
+ if candidates.empty?
15
+ say "\nNo project found matching '#{project}'", :red
16
+ else
17
+ result = select_project(candidates)
18
+ candidate = Zensana::Asana::Project.new(result)
19
+ say "\nProject attributes...", :green
20
+ puts candidate.attributes.pretty
21
+
22
+ if options[:tasks]
23
+ say "Associated tasks...", :green
24
+ puts candidate.task_list.pretty
25
+ end
26
+ end
27
+ end
28
+
29
+ desc 'convert PROJECT', 'Convert PROJECT tasks to ZenDesk tickets (exact ID or NAME required)'
30
+ option :attachments, type: 'boolean', aliases: '-a', default: true, desc: 'download and upload any attachments'
31
+ option :completed, type: 'boolean', aliases: '-c', default: false, desc: 'include tasks that are completed'
32
+ option :default_user, type: 'string', aliases: '-u', default: nil, desc: 'set a default user to assign to tickets'
33
+ # option :followers, type: 'boolean', aliases: '-f', default: false, desc: 'add followers of tasks to tickets'
34
+ option :stories, type: 'boolean', aliases: '-s', default: true, desc: 'import stories as comments'
35
+ option :verified, type: 'boolean', aliases: '-v', default: true, desc: '`false` will send email to zendesk users created'
36
+ def convert(project)
37
+ @asana_project = Zensana::Asana::Project.new(project)
38
+ say <<-BANNER
39
+
40
+ This will convert the following Asana project into ZenDesk:
41
+
42
+ id: #{@asana_project.id}
43
+ name: #{@asana_project.name}
44
+
45
+ using options #{options}
46
+
47
+ BANNER
48
+
49
+ unless yes?("Do you wish to proceed?", :yellow)
50
+ say "\nNothing else for me to do, exiting...\n", :red
51
+ exit
52
+ end
53
+
54
+ # LIFO tags for recursive tagging
55
+ #
56
+ # `project_tag_list` holds the project tags
57
+ # and also the tags for the current task and
58
+ # its parent tasks which are all added to the ticket
59
+ #
60
+ # `section_tag_list` holds the tags for the last
61
+ # section task which are also added to tickets
62
+ #
63
+ tags = [ 'zensana', 'imported' ]
64
+ project_tags = [] << tags
65
+ section_tags = []
66
+
67
+ @asana_project.full_tasks.each do |task|
68
+ task_to_ticket task, project_tags, section_tags
69
+ end
70
+ say "\n\n ---> Finished!\n\n", :green
71
+ end
72
+
73
+ private
74
+
75
+ # display list of projects by name
76
+ # and prompt for selection by index
77
+ #
78
+ # returns selected project_id
79
+ #
80
+ def select_project(array)
81
+ return array.first['id'] if array.size == 1
82
+
83
+ str_format = "\n %#{array.count.to_s.size}s: %s"
84
+ question = set_color "\nWhich project should I use?", :yellow
85
+ answers = {}
86
+
87
+ array.sort_by { |e| e['name'] }.each_with_index do |project, index|
88
+ i = (index + 1).to_s
89
+ answers[i] = project['id']
90
+ question << format(str_format, i, project['name'])
91
+ end
92
+
93
+ puts question
94
+ reply = ask("> ").to_s
95
+ if answers[reply]
96
+ answers[reply]
97
+ else
98
+ say "Not a valid selection, I'm out of here!", :red
99
+ exit 1
100
+ end
101
+ end
102
+
103
+ # convert and asana task into a zendesk ticket
104
+ # calls itself recursively for subtasks
105
+ #
106
+ def task_to_ticket(task, project_tags, section_tags )
107
+ if task.attributes['completed'] && !options[:completed]
108
+ say "\nSkipping completed task: #{task.name}! ", :yellow
109
+ return
110
+ end
111
+
112
+ # sections contribute their tags but nothing else
113
+ # and the same is true of tasks already created
114
+ if task.is_section?
115
+ say "\nProcessing section: #{task.section_name} "
116
+ section_tags.pop
117
+ section_tags.push task.tags << snake_case(task.section_name)
118
+ else
119
+ say "\nProcessing task: #{task.name} "
120
+ project_tags.push snake_case(task.tags)
121
+
122
+ if Zensana::Zendesk::Ticket.external_id_exists?(task.id)
123
+ say "\n >>> skip ticket creation, task already imported ", :yellow
124
+ else
125
+ requester = asana_to_zendesk_user(task.created_by, true)
126
+
127
+ # create comments from the task's stories
128
+ if options[:stories]
129
+
130
+ comments = []
131
+ task.stories.each do |story|
132
+ if story['type'] == 'comment' &&
133
+ (author = asana_to_zendesk_user(story['created_by'], true))
134
+ comments << Zensana::Zendesk::Comment.new(
135
+ :author_id => author.id,
136
+ :value => story['text'],
137
+ :created_at => story['created_at'],
138
+ :public => true
139
+ ).attributes
140
+ end
141
+ end
142
+
143
+ # process attachments on this task
144
+ if options[:attachments]
145
+ download_attachments task.attachments
146
+ if tokens = upload_attachments(task.attachments)
147
+ comments << Zensana::Zendesk::Comment.new(
148
+ :author_id => requester.id,
149
+ :value => 'Attachments from original Asana task',
150
+ :uploads => tokens,
151
+ :public => true
152
+ ).attributes
153
+ end
154
+ end
155
+ end
156
+
157
+ # if assignee is not an agent then leave unassigned
158
+ if (assignee_key = options[:default_user] || task.attributes['assignee'])
159
+ unless (assignee = asana_to_zendesk_user(assignee_key, false)) &&
160
+ (assignee.role != 'end-user')
161
+ assignee = nil
162
+ end
163
+ end
164
+
165
+ # ready to import the ticket now!
166
+ ticket = Zensana::Zendesk::Ticket.new(
167
+ :requester_id => requester.id,
168
+ :external_id => task.id,
169
+ :subject => task.name,
170
+ :description => <<-EOF,
171
+ This is an Asana task imported using zensana @ #{Time.now}
172
+
173
+ Project: #{@asana_project.name} (#{@asana_project.id})
174
+
175
+ Task notes: #{task.notes}
176
+
177
+ Task attributes: #{task.attributes}
178
+ EOF
179
+ :assignee_id => assignee ? assignee.id : '',
180
+ :created_at => task.created_at,
181
+ :tags => flatten_tags(project_tags, section_tags),
182
+ :comments => comments
183
+ )
184
+ ticket.import
185
+ end
186
+
187
+ # rinse and repeat for subtasks and their subtasks and ...
188
+ # we create a new section tag list for each recursed level
189
+ sub_section_tags = []
190
+ task.subtasks.each do |sub|
191
+ task_to_ticket Zensana::Asana::Task.new(sub.attributes['id']), project_tags, sub_section_tags
192
+ end
193
+
194
+ # this task's tags are now no longer required
195
+ project_tags.pop
196
+ end
197
+ end
198
+
199
+ # lookup up asana user on zendesk and
200
+ # optionally create new if not exists
201
+ #
202
+ # return: zendesk user or nil
203
+ #
204
+ def asana_to_zendesk_user(spec, create)
205
+ key = spec.is_a?(Hash) ? spec['id'] : spec
206
+ asana = Zensana::Asana::User.new(key)
207
+ zendesk = Zensana::Zendesk::User.new
208
+ zendesk.find(asana.email)
209
+ rescue NotFound
210
+ if create
211
+ zendesk.create(
212
+ :email => asana.email,
213
+ :name => asana.name,
214
+ :verified => options[:verified]
215
+ )
216
+ zendesk
217
+ else
218
+ nil
219
+ end
220
+ else
221
+ zendesk
222
+ end
223
+
224
+ # download the attachments to the local file system
225
+ # and retry a few times because internet
226
+ # the download process is restartable (idempotent)
227
+ #
228
+ def download_attachments(attachments)
229
+ return if attachments.nil? || attachments.empty?
230
+ say "\n >>> downloading attachments "
231
+
232
+ attachments.each do |attachment|
233
+ tries = 3
234
+ begin
235
+ attachment.download
236
+ print '.'
237
+ rescue
238
+ retry unless (tries-= 1).zero?
239
+ raise
240
+ end
241
+ end
242
+ end
243
+
244
+ # upload all of the attachments from the local file system
245
+ # and retry a few times because internet
246
+ #
247
+ # return: array of tokens
248
+ #
249
+ def upload_attachments(attachments)
250
+ return if attachments.nil? || attachments.empty?
251
+ say "\n >>> uploading attachments "
252
+
253
+ [].tap do |tokens|
254
+ attachments.each do |attachment|
255
+ tries = 3
256
+ begin
257
+ uploader = Zensana::Zendesk::Attachment.new
258
+ tokens << uploader.upload(attachment.full_path)['token']
259
+ print '.'
260
+ rescue
261
+ retry unless (tries-= 1).zero?
262
+ raise
263
+ end
264
+ end
265
+ end
266
+ end
267
+
268
+ # take multiple arrays and flatten them
269
+ #
270
+ # return: single array of unique tags
271
+ #
272
+ def flatten_tags(*args)
273
+ tags = []
274
+ args.each { |a| tags << a }
275
+ tags.flatten.uniq
276
+ end
277
+
278
+ def snake_case(thing)
279
+ case
280
+ when thing.is_a?(String)
281
+ snake_case_it thing
282
+ when thing.is_a?(Array)
283
+ thing.map { |a| snake_case a }
284
+ else
285
+ raise ArgumentError, "I don't know how to snake_case instances of #{thing.class}"
286
+ end
287
+ end
288
+
289
+ def snake_case_it(thing)
290
+ thing.gsub(/(.)([A-Z])/,'\1_\2').gsub(' ', '_').downcase
291
+ end
292
+ end
293
+ end
@@ -0,0 +1,62 @@
1
+ module Zensana
2
+ class Asana
3
+ class Attachment
4
+ include Zensana::Asana::Access
5
+
6
+ attr_reader :attributes
7
+
8
+ def initialize(id)
9
+ @attributes = fetch(id)
10
+ end
11
+
12
+ def download
13
+ download_file unless downloaded?
14
+ end
15
+
16
+ def download!
17
+ File.delete full_path if downloaded?
18
+ download_file
19
+ end
20
+
21
+ def downloaded?
22
+ File.exist? full_path
23
+ end
24
+
25
+ def full_path
26
+ File.join(file_dir, attributes['name'])
27
+ end
28
+
29
+ def method_missing(name, *args, &block)
30
+ attributes[name.to_s] || super
31
+ end
32
+
33
+ private
34
+
35
+ def file_dir
36
+ File.join(temp_dir, 'downloads', parent)
37
+ end
38
+
39
+ def temp_dir
40
+ ENV['ZENSANA_TEMP_DIR'] || '/tmp/zensana'
41
+ end
42
+
43
+ def parent
44
+ attributes['parent']['id'].to_s
45
+ end
46
+
47
+ def fetch(id)
48
+ asana_service.fetch("/attachments/#{id}")
49
+ end
50
+
51
+ def download_file
52
+ FileUtils.mkdir_p file_dir
53
+ result = File.open(full_path, "wb") do |f|
54
+ f.write HTTParty.get(self.download_url)
55
+ end
56
+
57
+ Zensana::Error.handle_https result
58
+ Zensana::Response.new(result).ok?
59
+ end
60
+ end
61
+ end
62
+ end