asanban 0.0.2 → 0.0.4
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +15 -0
- data/README.md +30 -19
- data/asanban.gemspec +2 -0
- data/bin/asanban-load +2 -151
- data/bin/asanban-service +4 -1
- data/lib/asanban/bulk_load.rb +161 -0
- data/lib/asanban/service.rb +13 -11
- data/lib/asanban/version.rb +1 -1
- data/spec/service_spec.rb +90 -0
- data/static/metrics.html +12 -4
- metadata +61 -20
checksums.yaml
ADDED
@@ -0,0 +1,15 @@
|
|
1
|
+
---
|
2
|
+
!binary "U0hBMQ==":
|
3
|
+
metadata.gz: !binary |-
|
4
|
+
MWQxYzY4OTJkMzJlOTdiMWQwMDE4MzM0NWFhODQxOWMwZWJkZmY1Yg==
|
5
|
+
data.tar.gz: !binary |-
|
6
|
+
MmY0MTYzM2RmYzNiNGQ1MzlmNWQwMjViOGUxYTg2YWQyYmRhNDZlNg==
|
7
|
+
SHA512:
|
8
|
+
metadata.gz: !binary |-
|
9
|
+
NzVkZWNiMjIzYzQ5YzRiYjI2NjA5ZWU0YzhmMjJmYjk2MWMzZTliOGZmMTIx
|
10
|
+
ZjYxYzY2MzVjYTk3YzgyNmNiYzEzNWI2NDA3NGY5NzdmNWFmYzZiMGE5YWY5
|
11
|
+
ODIzMTRjNzkyYjIxNDBlMTgyMTlkMzdiM2NhZmUzNzJhZjk1ZTg=
|
12
|
+
data.tar.gz: !binary |-
|
13
|
+
OWU5NTlmOWRkZjI4Y2I1NWI1MTczYTg3NWI5NGUzN2JlMzgwMjMwYjMyNDM0
|
14
|
+
ZjY5MWI5NTM4YTRkY2M2ZGQzYjRjMDBhYTRiZDdjZjlkNzVjOTVmYjViZDhk
|
15
|
+
N2JmMmYwNTliZTI0MjM4YzY2ZGM3NDlkZjgzYjgyOWEzYmE3YjM=
|
data/README.md
CHANGED
@@ -1,24 +1,35 @@
|
|
1
1
|
# Asanban
|
2
2
|
|
3
|
-
|
4
|
-
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
3
|
+
Tooling for using Asana with a Kanban system.
|
4
|
+
|
5
|
+
See: http://dev.bizo.com/2013/01/asanban-lean-development-with-asana-and.html
|
6
|
+
|
7
|
+
## Installation and Usage
|
8
|
+
|
9
|
+
0. Make sure you have ruby 1.9 and rubygems installed and available on your system path.
|
10
|
+
1. Create a directory which will contain a configuration file with your API and DB settings. You can call it "asana" or whatever you like.
|
11
|
+
2. Run: "gem install asanban" (or better yet, use bundler)
|
12
|
+
3. Install MongoDB on your local machine, if you haven't already.
|
13
|
+
4. Provision a production installation of MongoDB. A free account at mongolab should get you started if needed.
|
14
|
+
5. Create a YAML file called asana.yml in the directory you created in step 1. The file should have the following structure:
|
15
|
+
- mongodb_uri: mongodb://[user]:[password]@[server]:[port]/[db-name] (Note that this is for the production MongoDB installation)
|
16
|
+
- mongodb_dbname: [db-name] (for both environments)
|
17
|
+
- asana_api_key: [your Asana API key - available in the Asana UI]
|
18
|
+
- asana_workspace_id: [your Asana workspace ID]
|
19
|
+
- asana_project_id: [the ID of the Asana project for which you would like to track metrics]
|
20
|
+
- asana_beginning_milestone: [the name of the first stage in your Kanban system, eg. Dev Ready]
|
21
|
+
- asana_ending_milestone: [the name of the last stage in your Kanban system, eg. Production]
|
22
|
+
|
23
|
+
6. Run: "asanban-load local" (the bulk loader). This will create task, milestone and lead time data in your local MongoDB, in the DB with the name given in the configuration file specified above.
|
24
|
+
- Note: You must follow the Asana conventions outlined above for the bulk loader to work, particularly naming your priority headers as follows: "{STAGE NAME} ({WIP})" (parens must be specified around WIP limit).
|
25
|
+
7. If the process succeeded and the data created in your local MongoDB looks reasonable, you can go ahead and create the data in your production MongoDB instance by running: "asana-load prod"
|
26
|
+
8. Run: "asanban-service". This starts the web service that will serve up your aggregated lead time data.
|
27
|
+
9. You can hit http://localhost:4567/metrics?aggregate_by=month to see your data in JSON format. (You can also pass in "year" or "day" instead of "month".) There is also a very rough prototype of a page with a raphy-charts graph showing the data from the web service that you can hit by visiting http://localhost:4567/metrics.html . This is just an example of how to consume the data; it needs a lot of work.
|
28
|
+
10. Since the web service is implemented with Sinatra, you will want to create a create a rack-up file if you're going to deploy it to production (or Heroku). Do that by creating a file called config.ru in the directory you created in step 1, which contains the following:
|
29
|
+
|
30
|
+
- load Gem.bin_path('asanban', 'asanban-service')
|
31
|
+
|
32
|
+
11. Schedule the bulk loader in cron to run every night.
|
22
33
|
|
23
34
|
## Contributing
|
24
35
|
|
data/asanban.gemspec
CHANGED
data/bin/asanban-load
CHANGED
@@ -1,153 +1,4 @@
|
|
1
1
|
#!/usr/bin/env ruby
|
2
|
-
require "
|
3
|
-
require "json"
|
4
|
-
require "net/https"
|
5
|
-
require "mongo"
|
6
|
-
require "time"
|
7
|
-
require "yaml"
|
2
|
+
require "asanban/bulk_load"
|
8
3
|
|
9
|
-
|
10
|
-
end_story_id = end_story["id"]
|
11
|
-
start_story_id = start_story["id"]
|
12
|
-
start_timestamp = Time.parse(start_story["created_at"])
|
13
|
-
end_timestamp = Time.parse(end_story["created_at"])
|
14
|
-
elapsed_time_seconds = end_timestamp - start_timestamp
|
15
|
-
elapsed_days = elapsed_time_seconds / (60.0 * 60.0 * 24.0)
|
16
|
-
day = "#{end_timestamp.year}-#{end_timestamp.month}-#{end_timestamp.day}"
|
17
|
-
month = "#{end_timestamp.year}-#{end_timestamp.month}"
|
18
|
-
year = end_timestamp.year.to_s
|
19
|
-
|
20
|
-
record = {"day" => day, "month" => month, "year" => year, "task_id" => task_id,
|
21
|
-
"start_milestone" => start_milestone, "end_milestone" => end_milestone,
|
22
|
-
"start_story_id" => start_story_id, "end_story_id" => end_story_id,
|
23
|
-
"elapsed_days" => elapsed_days}
|
24
|
-
collection.remove("end_story_id" => end_story_id)
|
25
|
-
collection.insert(record)
|
26
|
-
record
|
27
|
-
end
|
28
|
-
|
29
|
-
if !File.exists?('asana.yml')
|
30
|
-
puts "Must specify configuration in asana.yml"
|
31
|
-
exit(1)
|
32
|
-
end
|
33
|
-
config = YAML::load(File.open('asana.yml'))
|
34
|
-
api_key = config['asana_api_key']
|
35
|
-
workspace_id = config['asana_workspace_id']
|
36
|
-
project_id = config['asana_project_id']
|
37
|
-
mongodb_uri = config['mongodb_uri']
|
38
|
-
mongodb_dbname = config['mongodb_dbname']
|
39
|
-
|
40
|
-
if (ARGV.count < 1 || ARGV.count > 2)
|
41
|
-
puts "Syntax is: bulkload {stage} [mode]"
|
42
|
-
puts "{stage} is 'LOCAL' or 'PROD'"
|
43
|
-
puts "[mode] can be 'tasks' (to create task data) or 'times' to create milestone and lead time data. Script will do both if not specified."
|
44
|
-
exit(1)
|
45
|
-
else
|
46
|
-
if (ARGV[0].downcase == "local")
|
47
|
-
conn = Mongo::Connection.new
|
48
|
-
elsif (ARGV[0].downcase == "prod")
|
49
|
-
conn = Mongo::Connection.from_uri(mongodb_uri)
|
50
|
-
else
|
51
|
-
puts "Invalid stage: #{ARGV[0]}"
|
52
|
-
exit(1)
|
53
|
-
end
|
54
|
-
if mode = ARGV[1] && !['tasks', 'times'].include?(mode)
|
55
|
-
puts "Invalid mode: #{mode}"
|
56
|
-
exit(1)
|
57
|
-
end
|
58
|
-
end
|
59
|
-
|
60
|
-
db = conn.db(mongodb_dbname)
|
61
|
-
tasks_collection = db["tasks"]
|
62
|
-
|
63
|
-
if (mode == 'tasks' || !mode)
|
64
|
-
puts "Creating times..."
|
65
|
-
uri = URI.parse("https://app.asana.com/api/1.0/projects/#{project_id}/tasks?opt_fields=id,name,assignee,assignee_status,created_at,completed,completed_at,due_on,followers,modified_at,name,notes,projects,parent")
|
66
|
-
http = Net::HTTP.new(uri.host, uri.port)
|
67
|
-
http.use_ssl = true
|
68
|
-
http.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
69
|
-
header = { "Content-Type" => "application/json" }
|
70
|
-
path = "#{uri.path}?#{uri.query}"
|
71
|
-
req = Net::HTTP::Get.new(path, header)
|
72
|
-
req.basic_auth(api_key, '')
|
73
|
-
res = http.start { |http| http.request(req) }
|
74
|
-
tasks = JSON.parse(res.body)
|
75
|
-
|
76
|
-
if tasks['errors']
|
77
|
-
puts "Server returned an error retrieving tasks: #{tasks['errors'][0]['message']}"
|
78
|
-
else
|
79
|
-
#old_tasks = tasks_collection.find({"projects.id" => project_id, "completed" => false}).to_a
|
80
|
-
tasks['data'].each_with_index do |task, i|
|
81
|
-
uri = URI.parse("https://app.asana.com/api/1.0/tasks/#{task['id']}/stories")
|
82
|
-
req = Net::HTTP::Get.new(uri.path, header)
|
83
|
-
req.basic_auth(api_key, '')
|
84
|
-
res = http.start { |http| http.request(req) }
|
85
|
-
stories = JSON.parse(res.body)
|
86
|
-
|
87
|
-
if task['errors']
|
88
|
-
puts "Server returned an error retrieving stories: #{task['errors'][0]['message']}"
|
89
|
-
else
|
90
|
-
task["stories"] = stories['data']
|
91
|
-
end
|
92
|
-
|
93
|
-
tasks_collection.update({"id" => task['id']}, task, {:upsert => true})
|
94
|
-
puts "Created task: #{task['id']}"
|
95
|
-
|
96
|
-
if (((i + 1) % 100) == 0)
|
97
|
-
puts "Sleeping for one minute to avoid Asana's rate limit of 100 requests per minute"
|
98
|
-
sleep 60
|
99
|
-
end
|
100
|
-
end
|
101
|
-
|
102
|
-
puts "Done creating times."
|
103
|
-
end
|
104
|
-
end
|
105
|
-
|
106
|
-
if (mode == 'times' || !mode)
|
107
|
-
puts "Creating milestone and lead time data..."
|
108
|
-
milestone_times_collection = db["milestone_times"]
|
109
|
-
lead_times_collection = db["lead_times"]
|
110
|
-
# TODO: filter - complete_time = null or completed_time - now <= 24hours
|
111
|
-
tasks_collection.find().each do |task|
|
112
|
-
stories = task["stories"]
|
113
|
-
task_id = task["_id"]
|
114
|
-
stories.each do |story|
|
115
|
-
if (story['text'] =~ /Moved from (.*)\(\d+\) to (.*)\(\d+\)/)
|
116
|
-
start_milestone = $1.strip
|
117
|
-
end_milestone = $2.strip
|
118
|
-
timestamp = Time.parse(story["created_at"])
|
119
|
-
day = "#{timestamp.year}-#{timestamp.month}-#{timestamp.day}"
|
120
|
-
month = "#{timestamp.year}-#{timestamp.month}"
|
121
|
-
year = timestamp.year.to_s
|
122
|
-
end_story_id = story["id"]
|
123
|
-
|
124
|
-
if (start_story = stories.find {|s| s['text'] =~ /Moved .*to #{start_milestone}/})
|
125
|
-
#TODO: Refactor to use record_time
|
126
|
-
start_story_id = start_story["id"]
|
127
|
-
start_timestamp = Time.parse(start_story["created_at"])
|
128
|
-
elapsed_time_seconds = timestamp - start_timestamp
|
129
|
-
elapsed_days = elapsed_time_seconds / (60.0 * 60.0 * 24.0)
|
130
|
-
|
131
|
-
milestone = {"day" => day, "month" => month, "year" => year, "task_id" => task_id,
|
132
|
-
"start_milestone" => start_milestone, "end_milestone" => end_milestone,
|
133
|
-
"start_story_id" => start_story_id, "end_story_id" => end_story_id,
|
134
|
-
"elapsed_days" => elapsed_days}
|
135
|
-
milestone_times_collection.remove("end_story_id" => end_story_id)
|
136
|
-
milestone_times_collection.insert(milestone)
|
137
|
-
puts "Inserted milestone: #{milestone}"
|
138
|
-
else
|
139
|
-
puts "Could not find time task entered #{start_milestone}"
|
140
|
-
end
|
141
|
-
end
|
142
|
-
end
|
143
|
-
|
144
|
-
if ((end_story = stories.find_all {|s| s['text'] =~ /Moved .*to #{config['asana_ending_milestone']}/}[-1]) &&
|
145
|
-
(start_story = stories.find_all {|s| s['text'] =~ /Moved .*to #{config['asana_beginning_milestone']}/}[0]))
|
146
|
-
lead_times_collection.remove("end_story_id" => end_story["id"])
|
147
|
-
lead_time = record_time(task_id, start_story, config['asana_beginning_milestone'], end_story, config['asana_ending_milestone'], lead_times_collection)
|
148
|
-
puts "Inserted lead time: #{lead_time}"
|
149
|
-
end
|
150
|
-
end
|
151
|
-
puts "Finished creating milestone and lead time data."
|
152
|
-
end
|
153
|
-
puts "Done!"
|
4
|
+
Asanban::BulkLoad.run()
|
data/bin/asanban-service
CHANGED
@@ -0,0 +1,161 @@
|
|
1
|
+
require "rubygems"
|
2
|
+
require "json"
|
3
|
+
require "net/https"
|
4
|
+
require "mongo"
|
5
|
+
require "time"
|
6
|
+
require "yaml"
|
7
|
+
|
8
|
+
module Asanban
|
9
|
+
class BulkLoad
|
10
|
+
def self.run(config = nil)
|
11
|
+
unless config
|
12
|
+
if !File.exists?('asana.yml')
|
13
|
+
puts "Must specify configuration in asana.yml"
|
14
|
+
exit(1)
|
15
|
+
end
|
16
|
+
config = YAML::load(File.open('asana.yml'))
|
17
|
+
end
|
18
|
+
api_key = config['asana_api_key']
|
19
|
+
workspace_id = config['asana_workspace_id']
|
20
|
+
project_id = config['asana_project_id']
|
21
|
+
mongodb_uri = config['mongodb_uri']
|
22
|
+
mongodb_dbname = config['mongodb_dbname']
|
23
|
+
|
24
|
+
if (ARGV.count < 1 || ARGV.count > 2)
|
25
|
+
puts "Syntax is: bulkload {stage} [mode]"
|
26
|
+
puts "{stage} is 'LOCAL' or 'PROD'"
|
27
|
+
puts "[mode] can be 'tasks' (to create task data) or 'times' to create milestone and lead time data. Script will do both if not specified."
|
28
|
+
exit(1)
|
29
|
+
else
|
30
|
+
if (ARGV[0].downcase == "local")
|
31
|
+
conn = Mongo::Connection.new
|
32
|
+
elsif (ARGV[0].downcase == "prod")
|
33
|
+
conn = Mongo::Connection.from_uri(mongodb_uri)
|
34
|
+
else
|
35
|
+
puts "Invalid stage: #{ARGV[0]}"
|
36
|
+
exit(1)
|
37
|
+
end
|
38
|
+
if mode = ARGV[1] && !['tasks', 'times'].include?(mode)
|
39
|
+
puts "Invalid mode: #{mode}"
|
40
|
+
exit(1)
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
db = conn.db(mongodb_dbname)
|
45
|
+
tasks_collection = db["tasks"]
|
46
|
+
|
47
|
+
if (mode == 'tasks' || !mode)
|
48
|
+
puts "Creating times..."
|
49
|
+
uri = URI.parse("https://app.asana.com/api/1.0/projects/#{project_id}/tasks?opt_fields=id,name,assignee,assignee_status,created_at,completed,completed_at,due_on,followers,modified_at,name,notes,projects,parent")
|
50
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
51
|
+
http.use_ssl = true
|
52
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
53
|
+
header = { "Content-Type" => "application/json" }
|
54
|
+
path = "#{uri.path}?#{uri.query}"
|
55
|
+
req = Net::HTTP::Get.new(path, header)
|
56
|
+
req.basic_auth(api_key, '')
|
57
|
+
res = http.start { |http| http.request(req) }
|
58
|
+
tasks = JSON.parse(res.body)
|
59
|
+
|
60
|
+
if tasks['errors']
|
61
|
+
puts "Server returned an error retrieving tasks: #{tasks['errors'][0]['message']}"
|
62
|
+
else
|
63
|
+
#old_tasks = tasks_collection.find({"projects.id" => project_id, "completed" => false}).to_a
|
64
|
+
tasks['data'].each_with_index do |task, i|
|
65
|
+
uri = URI.parse("https://app.asana.com/api/1.0/tasks/#{task['id']}/stories")
|
66
|
+
req = Net::HTTP::Get.new(uri.path, header)
|
67
|
+
req.basic_auth(api_key, '')
|
68
|
+
res = http.start { |http| http.request(req) }
|
69
|
+
stories = JSON.parse(res.body)
|
70
|
+
|
71
|
+
if task['errors']
|
72
|
+
puts "Server returned an error retrieving stories: #{task['errors'][0]['message']}"
|
73
|
+
else
|
74
|
+
task["stories"] = stories['data']
|
75
|
+
end
|
76
|
+
|
77
|
+
tasks_collection.update({"id" => task['id']}, task, {:upsert => true})
|
78
|
+
puts "Created task: #{task['id']}"
|
79
|
+
|
80
|
+
if (((i + 1) % 100) == 0)
|
81
|
+
puts "Sleeping for one minute to avoid Asana's rate limit of 100 requests per minute"
|
82
|
+
sleep 60
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
puts "Done creating times."
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
if (mode == 'times' || !mode)
|
91
|
+
puts "Creating milestone and lead time data..."
|
92
|
+
milestone_times_collection = db["milestone_times"]
|
93
|
+
lead_times_collection = db["lead_times"]
|
94
|
+
# TODO: filter - complete_time = null or completed_time - now <= 24hours
|
95
|
+
tasks_collection.find().each do |task|
|
96
|
+
stories = task["stories"]
|
97
|
+
task_id = task["_id"]
|
98
|
+
stories.each do |story|
|
99
|
+
if (story['text'] =~ /Moved from (.*)\(\d+\) to (.*)\(\d+\)/)
|
100
|
+
start_milestone = $1.strip
|
101
|
+
end_milestone = $2.strip
|
102
|
+
timestamp = Time.parse(story["created_at"])
|
103
|
+
day = "#{timestamp.year}-#{timestamp.month}-#{timestamp.day}"
|
104
|
+
month = "#{timestamp.year}-#{timestamp.month}"
|
105
|
+
year = timestamp.year.to_s
|
106
|
+
end_story_id = story["id"]
|
107
|
+
|
108
|
+
if (start_story = stories.find {|s| s['text'] =~ /Moved .*to #{start_milestone}/})
|
109
|
+
#TODO: Refactor to use record_time
|
110
|
+
start_story_id = start_story["id"]
|
111
|
+
start_timestamp = Time.parse(start_story["created_at"])
|
112
|
+
elapsed_time_seconds = timestamp - start_timestamp
|
113
|
+
elapsed_days = elapsed_time_seconds / (60.0 * 60.0 * 24.0)
|
114
|
+
|
115
|
+
milestone = {"day" => day, "month" => month, "year" => year, "task_id" => task_id,
|
116
|
+
"start_milestone" => start_milestone, "end_milestone" => end_milestone,
|
117
|
+
"start_story_id" => start_story_id, "end_story_id" => end_story_id,
|
118
|
+
"elapsed_days" => elapsed_days}
|
119
|
+
milestone_times_collection.remove("end_story_id" => end_story_id)
|
120
|
+
milestone_times_collection.insert(milestone)
|
121
|
+
puts "Inserted milestone: #{milestone}"
|
122
|
+
else
|
123
|
+
puts "Could not find time task entered #{start_milestone}"
|
124
|
+
end
|
125
|
+
end
|
126
|
+
end
|
127
|
+
|
128
|
+
if ((end_story = stories.find_all {|s| s['text'] =~ /Moved .*to #{config['asana_ending_milestone']}/}[-1]) &&
|
129
|
+
(start_story = stories.find_all {|s| s['text'] =~ /Moved .*to #{config['asana_beginning_milestone']}/}[0]))
|
130
|
+
lead_times_collection.remove("end_story_id" => end_story["id"])
|
131
|
+
lead_time = record_time(task_id, start_story, config['asana_beginning_milestone'], end_story, config['asana_ending_milestone'], lead_times_collection)
|
132
|
+
puts "Inserted lead time: #{lead_time}"
|
133
|
+
end
|
134
|
+
end
|
135
|
+
puts "Finished creating milestone and lead time data."
|
136
|
+
end
|
137
|
+
puts "Done!"
|
138
|
+
end
|
139
|
+
|
140
|
+
#Visible For Testing
|
141
|
+
def self.record_time(task_id, start_story, start_milestone, end_story, end_milestone, collection)
|
142
|
+
end_story_id = end_story["id"]
|
143
|
+
start_story_id = start_story["id"]
|
144
|
+
start_timestamp = Time.parse(start_story["created_at"])
|
145
|
+
end_timestamp = Time.parse(end_story["created_at"])
|
146
|
+
elapsed_time_seconds = end_timestamp - start_timestamp
|
147
|
+
elapsed_days = elapsed_time_seconds / (60.0 * 60.0 * 24.0)
|
148
|
+
day = "#{end_timestamp.year}-#{end_timestamp.month}-#{end_timestamp.day}"
|
149
|
+
month = "#{end_timestamp.year}-#{end_timestamp.month}"
|
150
|
+
year = end_timestamp.year.to_s
|
151
|
+
|
152
|
+
record = {"day" => day, "month" => month, "year" => year, "task_id" => task_id,
|
153
|
+
"start_milestone" => start_milestone, "end_milestone" => end_milestone,
|
154
|
+
"start_story_id" => start_story_id, "end_story_id" => end_story_id,
|
155
|
+
"elapsed_days" => elapsed_days}
|
156
|
+
collection.remove("end_story_id" => end_story_id)
|
157
|
+
collection.insert(record)
|
158
|
+
record
|
159
|
+
end
|
160
|
+
end
|
161
|
+
end
|
data/lib/asanban/service.rb
CHANGED
@@ -9,13 +9,16 @@ module Asanban
|
|
9
9
|
set :public_folder, File.expand_path(File.join('..', '..', '..', 'static'), __FILE__)
|
10
10
|
set :port, ENV['PORT'] if ENV['PORT'] #set by Heroku
|
11
11
|
|
12
|
-
|
13
|
-
|
14
|
-
|
12
|
+
configure :production, :development do
|
13
|
+
if !File.exists?('asana.yml')
|
14
|
+
puts "Must specify configuration in asana.yml"
|
15
|
+
exit(1)
|
16
|
+
end
|
17
|
+
set :config, YAML::load(File.open('asana.yml'))
|
15
18
|
end
|
16
|
-
config = YAML::load(File.open('asana.yml'))
|
17
19
|
|
18
20
|
get '/metrics' do
|
21
|
+
config = settings.config
|
19
22
|
conn = Mongo::Connection.from_uri(config['mongodb_uri'])
|
20
23
|
db = conn.db(config["mongodb_dbname"])
|
21
24
|
aggregate_by = params[:aggregate_by]
|
@@ -49,13 +52,12 @@ module Asanban
|
|
49
52
|
|
50
53
|
content_type :json
|
51
54
|
results.find().map do |result|
|
52
|
-
|
53
|
-
|
54
|
-
[date_str.sub("-","").to_i, result["value"]["avg"]]
|
55
|
-
end.sort {|r| r[0]}.to_json
|
55
|
+
[result['_id'], result["value"]["avg"]]
|
56
|
+
end.sort {|a, b| datestring_to_int(a[0]) <=> datestring_to_int(b[0])}.to_json
|
56
57
|
end
|
57
|
-
end
|
58
58
|
|
59
|
-
|
60
|
-
|
59
|
+
def datestring_to_int(datestring)
|
60
|
+
datestring.split("-").map {|part| part.length == 1 ? "0" + part : part}.join("").to_i
|
61
|
+
end
|
62
|
+
end
|
61
63
|
end
|
data/lib/asanban/version.rb
CHANGED
@@ -0,0 +1,90 @@
|
|
1
|
+
ENV['RACK_ENV'] = 'test'
|
2
|
+
require 'asanban/service'
|
3
|
+
require 'asanban/bulk_load'
|
4
|
+
require 'rspec'
|
5
|
+
require 'rack/test'
|
6
|
+
|
7
|
+
describe Asanban::Service do
|
8
|
+
include Rack::Test::Methods
|
9
|
+
|
10
|
+
before do
|
11
|
+
config = { "mongodb_uri" => "mongodb://localhost/", "mongodb_dbname" => "asana_test" }
|
12
|
+
app.settings.set(:config, config)
|
13
|
+
conn = Mongo::Connection.from_uri(config['mongodb_uri'])
|
14
|
+
db = conn.db(config["mongodb_dbname"])
|
15
|
+
@milestone_times_collection = db["milestone_times"]
|
16
|
+
@milestone_times_collection.drop
|
17
|
+
@lead_times_collection = db["lead_times"]
|
18
|
+
@lead_times_collection.drop
|
19
|
+
end
|
20
|
+
|
21
|
+
it "returns average lead times by year" do
|
22
|
+
record_lead_time('Foo', "October 2, 2012", "October 14, 2012") #12 days
|
23
|
+
record_lead_time('Bar', "May 12, 2013", "May 14, 2013") #2 days
|
24
|
+
record_lead_time('Baz', "October 12, 2013", "October 16, 2013") #4 days
|
25
|
+
|
26
|
+
get '/metrics', :aggregate_by => 'year'
|
27
|
+
#Average: 12 days 2012, 3 days 2013
|
28
|
+
expect(last_response.body).to eq('[["2012",12.0],["2013",3.0]]')
|
29
|
+
end
|
30
|
+
|
31
|
+
it "returns average lead times by month" do
|
32
|
+
record_lead_time('Foo', "September 20, 2013", "September 30, 2013") #10 days
|
33
|
+
record_lead_time('Bar', "October 2, 2013", "October 14, 2013") #12 days
|
34
|
+
record_lead_time('Baz', "October 12, 2013", "October 16, 2013") #4 days
|
35
|
+
|
36
|
+
get '/metrics', :aggregate_by => 'month'
|
37
|
+
#Average: 10 days September, 8 days in October
|
38
|
+
expect(last_response.body).to eq('[["2013-9",10.0],["2013-10",8.0]]')
|
39
|
+
end
|
40
|
+
|
41
|
+
it "returns average lead times by day" do
|
42
|
+
record_lead_time('Foo', "October 2, 2013", "October 14, 2013") #12 days
|
43
|
+
record_lead_time('Bar', "October 12, 2013", "October 14, 2013") #2 days
|
44
|
+
record_lead_time('Baz', "October 12, 2013", "October 16, 2013") #4 days
|
45
|
+
|
46
|
+
get '/metrics', :aggregate_by => 'day'
|
47
|
+
#Average: 7 days 10/14, 4 days 10/16
|
48
|
+
expect(last_response.body).to eq('[["2013-10-14",7.0],["2013-10-16",4.0]]')
|
49
|
+
end
|
50
|
+
|
51
|
+
describe "milestone times recorded" do
|
52
|
+
before do
|
53
|
+
record_time('Foo', "September 1, 2013", "September 3, 2013", 'Dev Ready', 'Dev In Progress', @milestone_times_collection) #2
|
54
|
+
record_time('Foo', "September 3, 2013", "September 10, 2013", 'Dev In Progress', 'Dev Done', @milestone_times_collection) #7
|
55
|
+
record_time('Foo', "September 10, 2013", "September 15, 2013", 'Dev Done', 'PM Test, in Dev', @milestone_times_collection) #5
|
56
|
+
record_time('Bar', "September 1, 2013", "September 7, 2013", 'Dev Ready', 'Dev In Progress', @milestone_times_collection) #6
|
57
|
+
record_time('Bar', "September 7, 2013", "September 12, 2013", 'Dev In Progress', 'Dev Done', @milestone_times_collection) #5
|
58
|
+
record_time('Bar', "September 12, 2013", "September 15, 2013", 'Dev Done', 'PM Test, in Dev', @milestone_times_collection) #3
|
59
|
+
end
|
60
|
+
|
61
|
+
it "returns average time in 'Dev Ready' by month" do
|
62
|
+
get '/metrics', :aggregate_by => 'month', :milestone => 'Dev Ready'
|
63
|
+
expect(last_response.body).to eq('[["2013-9",4.0]]')
|
64
|
+
end
|
65
|
+
|
66
|
+
it "returns average time in 'Dev In Progress' by month" do
|
67
|
+
get '/metrics', :aggregate_by => 'month', :milestone => 'Dev In Progress'
|
68
|
+
expect(last_response.body).to eq('[["2013-9",6.0]]')
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
it "returns an error when an invalid aggregation is specified" do
|
73
|
+
get '/metrics', :aggregate_by => 'engineer'
|
74
|
+
expect(last_response.status).to eq(400)
|
75
|
+
end
|
76
|
+
|
77
|
+
def app
|
78
|
+
Asanban::Service
|
79
|
+
end
|
80
|
+
|
81
|
+
def record_lead_time(task_id, started_at, ended_at)
|
82
|
+
record_time(task_id, started_at, ended_at, 'Dev Ready', 'Production', @lead_times_collection)
|
83
|
+
end
|
84
|
+
|
85
|
+
def record_time(task_id, started_at, ended_at, start_milestone, end_milestone, collection)
|
86
|
+
start_story = {'id' => "#{task_id}_#{start_milestone}", 'created_at' => started_at}
|
87
|
+
end_story = {'id' => "#{task_id}_#{end_milestone}", 'created_at' => ended_at}
|
88
|
+
Asanban::BulkLoad.record_time(task_id, start_story, start_milestone, end_story, end_milestone, collection)
|
89
|
+
end
|
90
|
+
end
|
data/static/metrics.html
CHANGED
@@ -1,14 +1,17 @@
|
|
1
1
|
<html>
|
2
2
|
<head>
|
3
3
|
<script src="http://ajax.aspnetcdn.com/ajax/jQuery/jquery-1.8.3.min.js"></script>
|
4
|
-
|
5
|
-
|
4
|
+
<script src="https://rawgithub.com/DmitryBaranovskiy/raphael/master/raphael-min.js"></script>
|
5
|
+
<script src="https://rawgithub.com/jcarver989/raphy-charts/master/compiled/charts.min.js"></script>
|
6
6
|
<script type="text/javascript">
|
7
7
|
$(document).ready(function() {
|
8
|
-
var chart = new Charts.LineChart('leadtime_chart');
|
8
|
+
var chart = new Charts.LineChart('leadtime_chart', { label_format: "%m/%d/%Y" });
|
9
9
|
$.get("/metrics?aggregate_by=month", function(metric_data) {
|
10
|
+
var data = metric_data.map(function(item) {
|
11
|
+
return [parse(item[0]), item[1]];
|
12
|
+
});
|
10
13
|
chart.add_line({
|
11
|
-
data:
|
14
|
+
data: data,
|
12
15
|
options: {
|
13
16
|
line_color: "#00aadd",
|
14
17
|
x_label_size: 40
|
@@ -16,6 +19,11 @@
|
|
16
19
|
});
|
17
20
|
chart.draw();
|
18
21
|
});
|
22
|
+
|
23
|
+
function parse(month_str) {
|
24
|
+
var month_parts = month_str.split("-");
|
25
|
+
return new Date(month_parts[0], month_parts[1] - 1, "1");
|
26
|
+
}
|
19
27
|
});
|
20
28
|
</script>
|
21
29
|
</head>
|
metadata
CHANGED
@@ -1,60 +1,99 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: asanban
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.0.
|
5
|
-
prerelease:
|
4
|
+
version: 0.0.4
|
6
5
|
platform: ruby
|
7
6
|
authors:
|
8
7
|
- Pat Gannon
|
9
8
|
autorequire:
|
10
9
|
bindir: bin
|
11
10
|
cert_chain: []
|
12
|
-
date: 2013-
|
11
|
+
date: 2013-10-30 00:00:00.000000000 Z
|
13
12
|
dependencies:
|
14
13
|
- !ruby/object:Gem::Dependency
|
15
14
|
name: mongo
|
16
|
-
requirement:
|
17
|
-
none: false
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
18
16
|
requirements:
|
19
17
|
- - ! '>='
|
20
18
|
- !ruby/object:Gem::Version
|
21
19
|
version: '0'
|
22
20
|
type: :runtime
|
23
21
|
prerelease: false
|
24
|
-
version_requirements:
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - ! '>='
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '0'
|
25
27
|
- !ruby/object:Gem::Dependency
|
26
28
|
name: json
|
27
|
-
requirement:
|
28
|
-
none: false
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
29
30
|
requirements:
|
30
31
|
- - ! '>='
|
31
32
|
- !ruby/object:Gem::Version
|
32
33
|
version: '0'
|
33
34
|
type: :runtime
|
34
35
|
prerelease: false
|
35
|
-
version_requirements:
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - ! '>='
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '0'
|
36
41
|
- !ruby/object:Gem::Dependency
|
37
42
|
name: json_pure
|
38
|
-
requirement:
|
39
|
-
none: false
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
40
44
|
requirements:
|
41
45
|
- - ! '>='
|
42
46
|
- !ruby/object:Gem::Version
|
43
47
|
version: '0'
|
44
48
|
type: :runtime
|
45
49
|
prerelease: false
|
46
|
-
version_requirements:
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - ! '>='
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '0'
|
47
55
|
- !ruby/object:Gem::Dependency
|
48
56
|
name: sinatra
|
49
|
-
requirement:
|
50
|
-
none: false
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
51
58
|
requirements:
|
52
59
|
- - ! '>='
|
53
60
|
- !ruby/object:Gem::Version
|
54
61
|
version: '0'
|
55
62
|
type: :runtime
|
56
63
|
prerelease: false
|
57
|
-
version_requirements:
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - ! '>='
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '0'
|
69
|
+
- !ruby/object:Gem::Dependency
|
70
|
+
name: rspec
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
72
|
+
requirements:
|
73
|
+
- - ! '>='
|
74
|
+
- !ruby/object:Gem::Version
|
75
|
+
version: '0'
|
76
|
+
type: :development
|
77
|
+
prerelease: false
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
79
|
+
requirements:
|
80
|
+
- - ! '>='
|
81
|
+
- !ruby/object:Gem::Version
|
82
|
+
version: '0'
|
83
|
+
- !ruby/object:Gem::Dependency
|
84
|
+
name: rack-test
|
85
|
+
requirement: !ruby/object:Gem::Requirement
|
86
|
+
requirements:
|
87
|
+
- - ! '>='
|
88
|
+
- !ruby/object:Gem::Version
|
89
|
+
version: '0'
|
90
|
+
type: :development
|
91
|
+
prerelease: false
|
92
|
+
version_requirements: !ruby/object:Gem::Requirement
|
93
|
+
requirements:
|
94
|
+
- - ! '>='
|
95
|
+
- !ruby/object:Gem::Version
|
96
|
+
version: '0'
|
58
97
|
description: ''
|
59
98
|
email:
|
60
99
|
- gannon@bizo.com
|
@@ -73,31 +112,33 @@ files:
|
|
73
112
|
- bin/asanban-load
|
74
113
|
- bin/asanban-service
|
75
114
|
- lib/asanban.rb
|
115
|
+
- lib/asanban/bulk_load.rb
|
76
116
|
- lib/asanban/service.rb
|
77
117
|
- lib/asanban/version.rb
|
118
|
+
- spec/service_spec.rb
|
78
119
|
- static/metrics.html
|
79
120
|
homepage: ''
|
80
121
|
licenses: []
|
122
|
+
metadata: {}
|
81
123
|
post_install_message:
|
82
124
|
rdoc_options: []
|
83
125
|
require_paths:
|
84
126
|
- lib
|
85
127
|
required_ruby_version: !ruby/object:Gem::Requirement
|
86
|
-
none: false
|
87
128
|
requirements:
|
88
129
|
- - ! '>='
|
89
130
|
- !ruby/object:Gem::Version
|
90
131
|
version: '0'
|
91
132
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
92
|
-
none: false
|
93
133
|
requirements:
|
94
134
|
- - ! '>='
|
95
135
|
- !ruby/object:Gem::Version
|
96
136
|
version: '0'
|
97
137
|
requirements: []
|
98
138
|
rubyforge_project:
|
99
|
-
rubygems_version: 1.
|
139
|
+
rubygems_version: 2.1.4
|
100
140
|
signing_key:
|
101
|
-
specification_version:
|
141
|
+
specification_version: 4
|
102
142
|
summary: Track Kanban metrics using Asana as a data source
|
103
|
-
test_files:
|
143
|
+
test_files:
|
144
|
+
- spec/service_spec.rb
|