zensana 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
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