asanban 0.0.4 → 0.0.5
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +8 -8
- data/asanban.gemspec +1 -0
- data/lib/asanban.rb +2 -1
- data/lib/asanban/bulk_load.rb +148 -138
- data/lib/asanban/service.rb +122 -55
- data/lib/asanban/version.rb +1 -1
- data/spec/service_spec.rb +148 -56
- data/static/dashboard.html +44 -0
- data/static/js/controllers.js +28 -0
- metadata +18 -2
checksums.yaml
CHANGED
@@ -1,15 +1,15 @@
|
|
1
1
|
---
|
2
2
|
!binary "U0hBMQ==":
|
3
3
|
metadata.gz: !binary |-
|
4
|
-
|
4
|
+
Y2VmZjRhNTI3ZGE1OTUyZjYzYWE1OTliNDU1MmFlZWViNWU0OGIwMQ==
|
5
5
|
data.tar.gz: !binary |-
|
6
|
-
|
6
|
+
MTkxMjA5OGQzMGM5NzBlODFiNjMyNmE2MDdmNDI2OTA2MTVlYzAwYQ==
|
7
7
|
SHA512:
|
8
8
|
metadata.gz: !binary |-
|
9
|
-
|
10
|
-
|
11
|
-
|
9
|
+
ZjRhMTdkYjdhYmNmNTFkYzJkM2NmYjIwY2M1MjE5ZmVhNzFiMThlODc4M2Zi
|
10
|
+
MWMyODJjNjYyY2I4MzY3NjY2ZjJlMTJjMDJkNGI5ZjgxNDBiNjU1ZDY0N2Ni
|
11
|
+
YzAyMTU5OTBlZmJjYTExNDg3MWY1YzAzY2YxZTM5MmRlYjEwM2I=
|
12
12
|
data.tar.gz: !binary |-
|
13
|
-
|
14
|
-
|
15
|
-
|
13
|
+
NGY3MDViYTU2ZDA0NDZjMDlkM2Y0ZjljNjUxODVlYjA5YWExZTAzN2JhYzI3
|
14
|
+
ODE5NDZkZThlMmJjMjA0ZWE4MDdiMTJkNWJlNzBjYTQ0MDk2NDM3Yzk4NDRi
|
15
|
+
ZTk0ZmQwOTYxOWQ2MDExOWYyOTYxY2MwNjhhNTBlZjk3ODNjYTI=
|
data/asanban.gemspec
CHANGED
@@ -20,6 +20,7 @@ Gem::Specification.new do |gem|
|
|
20
20
|
gem.add_dependency "json"
|
21
21
|
gem.add_dependency "json_pure"
|
22
22
|
gem.add_dependency "sinatra"
|
23
|
+
gem.add_dependency "descriptive_statistics"
|
23
24
|
gem.add_development_dependency "rspec"
|
24
25
|
gem.add_development_dependency "rack-test"
|
25
26
|
end
|
data/lib/asanban.rb
CHANGED
data/lib/asanban/bulk_load.rb
CHANGED
@@ -6,156 +6,166 @@ require "time"
|
|
6
6
|
require "yaml"
|
7
7
|
|
8
8
|
module Asanban
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
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
23
|
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
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
43
|
|
44
|
-
|
45
|
-
|
44
|
+
db = conn.db(mongodb_dbname)
|
45
|
+
tasks_collection = db["tasks"]
|
46
46
|
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
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
59
|
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
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_collection.update({}, {"$set" => {"old" => true}}, {:multi => true})
|
65
|
+
tasks['data'].each_with_index do |task, i|
|
66
|
+
uri = URI.parse("https://app.asana.com/api/1.0/tasks/#{task['id']}/stories")
|
67
|
+
req = Net::HTTP::Get.new(uri.path, header)
|
68
|
+
req.basic_auth(api_key, '')
|
69
|
+
res = http.start { |http| http.request(req) }
|
70
|
+
stories = JSON.parse(res.body)
|
70
71
|
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
72
|
+
if task['errors']
|
73
|
+
puts "Server returned an error retrieving stories: #{task['errors'][0]['message']}"
|
74
|
+
else
|
75
|
+
task["stories"] = stories['data']
|
76
|
+
end
|
76
77
|
|
77
|
-
|
78
|
-
|
78
|
+
task["old"] = false
|
79
|
+
tasks_collection.update({"id" => task['id']}, task, {:upsert => true})
|
80
|
+
puts "Created task: #{task['id']}"
|
79
81
|
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
82
|
+
if (((i + 1) % 100) == 0)
|
83
|
+
puts "Sleeping for one minute to avoid Asana's rate limit of 100 requests per minute"
|
84
|
+
sleep 60
|
85
|
+
end
|
86
|
+
end
|
85
87
|
|
86
|
-
|
87
|
-
|
88
|
-
|
88
|
+
puts "Done creating times."
|
89
|
+
end
|
90
|
+
end
|
89
91
|
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
|
92
|
+
if (mode == 'times' || !mode)
|
93
|
+
puts "Creating milestone and lead time data..."
|
94
|
+
milestone_times_collection = db["milestone_times"]
|
95
|
+
lead_times_collection = db["lead_times"]
|
96
|
+
# TODO: filter - complete_time = null or completed_time - now <= 24hours
|
97
|
+
tasks_collection.find().each do |task|
|
98
|
+
stories = task["stories"] || []
|
99
|
+
task_id = task["_id"]
|
100
|
+
#TODO: Write a test...
|
101
|
+
task_completed = task["completed"]
|
102
|
+
task_deleted = task["old"]
|
103
|
+
stories.each do |story|
|
104
|
+
if (story['text'] =~ /Moved from (.*)\(\d+\) to (.*)\(\d+\)/)
|
105
|
+
start_milestone = $1.strip
|
106
|
+
end_milestone = $2.strip
|
107
|
+
timestamp = Time.parse(story["created_at"])
|
108
|
+
day = "#{timestamp.year}-#{timestamp.month}-#{timestamp.day}"
|
109
|
+
month = "#{timestamp.year}-#{timestamp.month}"
|
110
|
+
year = timestamp.year.to_s
|
111
|
+
end_story_id = story["id"]
|
112
|
+
escaped_milestone = start_milestone.gsub('(', '\\(').gsub(')', '\\)')
|
107
113
|
|
108
|
-
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
|
113
|
-
|
114
|
+
if (start_story = stories.find {|s| s['text'] =~ /Moved .*to #{escaped_milestone}/})
|
115
|
+
#TODO: Refactor to use record_time
|
116
|
+
start_story_id = start_story["id"]
|
117
|
+
start_timestamp = Time.parse(start_story["created_at"])
|
118
|
+
elapsed_time_seconds = timestamp - start_timestamp
|
119
|
+
elapsed_days = elapsed_time_seconds / (60.0 * 60.0 * 24.0)
|
114
120
|
|
115
|
-
|
116
|
-
|
117
|
-
|
118
|
-
|
119
|
-
|
120
|
-
|
121
|
-
|
122
|
-
|
123
|
-
|
124
|
-
|
125
|
-
|
126
|
-
|
121
|
+
milestone = {"day" => day, "month" => month, "year" => year,
|
122
|
+
"task_id" => task_id, "date" => Time.parse(day),
|
123
|
+
"task_completed" => task_completed, "task_deleted" => task_deleted,
|
124
|
+
"start_milestone" => start_milestone, "end_milestone" => end_milestone,
|
125
|
+
"start_story_id" => start_story_id, "end_story_id" => end_story_id,
|
126
|
+
"elapsed_days" => elapsed_days}
|
127
|
+
milestone_times_collection.remove("end_story_id" => end_story_id)
|
128
|
+
milestone_times_collection.insert(milestone)
|
129
|
+
puts "Inserted milestone: #{milestone}"
|
130
|
+
else
|
131
|
+
puts "Could not find time task entered #{start_milestone}"
|
132
|
+
end
|
133
|
+
end
|
134
|
+
end
|
127
135
|
|
128
|
-
|
129
|
-
|
130
|
-
|
131
|
-
|
132
|
-
|
133
|
-
|
134
|
-
|
135
|
-
|
136
|
-
|
137
|
-
|
138
|
-
|
136
|
+
if ((end_story = stories.find_all {|s| s['text'] =~ /Moved .*to #{config['asana_ending_milestone']}/}[-1]) &&
|
137
|
+
(start_story = stories.find_all {|s| s['text'] =~ /Moved .*to #{config['asana_beginning_milestone']}/}[0]))
|
138
|
+
lead_times_collection.remove("end_story_id" => end_story["id"])
|
139
|
+
lead_time = record_time(task_id, task_completed, task_deleted, start_story, config['asana_beginning_milestone'], end_story, config['asana_ending_milestone'], lead_times_collection)
|
140
|
+
puts "Inserted lead time: #{lead_time}"
|
141
|
+
end
|
142
|
+
end
|
143
|
+
puts "Finished creating milestone and lead time data."
|
144
|
+
end
|
145
|
+
puts "Done!"
|
146
|
+
end
|
139
147
|
|
140
|
-
|
141
|
-
|
142
|
-
|
143
|
-
|
144
|
-
|
145
|
-
|
146
|
-
|
147
|
-
|
148
|
-
|
149
|
-
|
150
|
-
|
148
|
+
#Visible For Testing
|
149
|
+
def self.record_time(task_id, task_completed, task_deleted, start_story, start_milestone, end_story, end_milestone, collection)
|
150
|
+
end_story_id = end_story["id"]
|
151
|
+
start_story_id = start_story["id"]
|
152
|
+
start_timestamp = Time.parse(start_story["created_at"])
|
153
|
+
end_timestamp = Time.parse(end_story["created_at"])
|
154
|
+
elapsed_time_seconds = end_timestamp - start_timestamp
|
155
|
+
elapsed_days = elapsed_time_seconds / (60.0 * 60.0 * 24.0)
|
156
|
+
day = "#{end_timestamp.year}-#{end_timestamp.month}-#{end_timestamp.day}"
|
157
|
+
month = "#{end_timestamp.year}-#{end_timestamp.month}"
|
158
|
+
year = end_timestamp.year.to_s
|
151
159
|
|
152
|
-
|
153
|
-
|
154
|
-
|
155
|
-
|
156
|
-
|
157
|
-
|
158
|
-
|
159
|
-
|
160
|
-
|
160
|
+
record = {"day" => day, "month" => month, "year" => year,
|
161
|
+
"task_id" => task_id, "date" => Time.parse(day),
|
162
|
+
"task_completed" => task_completed, "task_deleted" => task_deleted,
|
163
|
+
"start_milestone" => start_milestone, "end_milestone" => end_milestone,
|
164
|
+
"start_story_id" => start_story_id, "end_story_id" => end_story_id,
|
165
|
+
"elapsed_days" => elapsed_days}
|
166
|
+
collection.remove("end_story_id" => end_story_id)
|
167
|
+
collection.insert(record)
|
168
|
+
record
|
169
|
+
end
|
170
|
+
end
|
161
171
|
end
|
data/lib/asanban/service.rb
CHANGED
@@ -3,61 +3,128 @@ require "json"
|
|
3
3
|
require "mongo"
|
4
4
|
require "sinatra"
|
5
5
|
require "yaml"
|
6
|
+
require "descriptive_statistics"
|
6
7
|
|
7
8
|
module Asanban
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
9
|
+
class Service < Sinatra::Base
|
10
|
+
set :public_folder, File.expand_path(File.join('..', '..', '..', 'static'), __FILE__)
|
11
|
+
set :port, ENV['PORT'] if ENV['PORT'] #set by Heroku
|
12
|
+
|
13
|
+
configure :production, :development do
|
14
|
+
if !File.exists?('asana.yml')
|
15
|
+
puts "Must specify configuration in asana.yml"
|
16
|
+
exit(1)
|
17
|
+
end
|
18
|
+
set :config, YAML::load(File.open('asana.yml'))
|
19
|
+
end
|
20
|
+
|
21
|
+
get '/metrics' do
|
22
|
+
config = settings.config
|
23
|
+
conn = Mongo::Connection.from_uri(config['mongodb_uri'])
|
24
|
+
db = conn.db(config["mongodb_dbname"])
|
25
|
+
aggregate_by = params[:aggregate_by]
|
26
|
+
return [400, "Cannot aggregate by #{aggregate_by}"] unless ["year", "month", "day", "start_milestone"].include? aggregate_by
|
27
|
+
content_type :json
|
28
|
+
|
29
|
+
map_function = "function() { emit(this.#{aggregate_by}, { count: 1, elapsed_days: this.elapsed_days }); };"
|
30
|
+
|
31
|
+
reduce_function = "function (name, values){
|
32
|
+
var n = {count : 0, elapsed_days : 0};
|
33
|
+
for ( var i=0; i<values.length; i++ ){
|
34
|
+
n.count += values[i].count;
|
35
|
+
n.elapsed_days += values[i].elapsed_days;
|
36
|
+
}
|
37
|
+
return n;
|
38
|
+
};"
|
39
|
+
|
40
|
+
finalize_function = "function(who, res){
|
41
|
+
res.avg = res.elapsed_days / res.count;
|
42
|
+
return res;
|
43
|
+
};"
|
44
|
+
|
45
|
+
map_reduce_options = {:finalize => finalize_function, :out => "mr_results"}
|
46
|
+
|
47
|
+
if (aggregate_by == "start_milestone")
|
48
|
+
results = db["milestone_times"].map_reduce(map_function, reduce_function, map_reduce_options)
|
49
|
+
hashes = results.find().map do |result|
|
50
|
+
{result['_id'] =>
|
51
|
+
{"count" => result["value"]["count"],
|
52
|
+
"cycle_time" => result["value"]["avg"]}}
|
53
|
+
end
|
54
|
+
#TODO: Refactor
|
55
|
+
hash = {}
|
56
|
+
hashes.map do |h|
|
57
|
+
hash.merge! h
|
58
|
+
end
|
59
|
+
|
60
|
+
#TODO: Move this (and other M/Rs?) to bulk loader
|
61
|
+
map_function = "function() { emit(this.task_id, {end_milestone: this.end_milestone, end_story_id: this.end_story_id, day: this.day}); };"
|
62
|
+
reduce_function = "function (name, values){
|
63
|
+
var n = {end_milestone : '', end_story_id : 0};
|
64
|
+
for ( var i=0; i<values.length; i++ ){
|
65
|
+
if (values[i].end_story_id > n.end_story_id) {
|
66
|
+
n.end_milestone = values[i].end_milestone;
|
67
|
+
n.end_story_id = values[i].end_story_id;
|
68
|
+
n.day = values[i].day;
|
69
|
+
}
|
70
|
+
}
|
71
|
+
return n;
|
72
|
+
};"
|
73
|
+
|
74
|
+
query = {"task_completed" => false, "task_deleted" => false}
|
75
|
+
if ((start_date = params[:start_date]) && (end_date = params[:end_date]))
|
76
|
+
query["date"] = {"$gte" => Time.parse(start_date), "$lte" => Time.parse(end_date)}
|
77
|
+
end
|
78
|
+
results = db["milestone_times"].map_reduce(map_function, reduce_function, :query => query, :out => "mr_end_milestone")
|
79
|
+
elapsed_days_by_phase = {}
|
80
|
+
results.find().map do |result|
|
81
|
+
task_id = result['_id']
|
82
|
+
end_milestone = result["value"]["end_milestone"]
|
83
|
+
if (end_milestone == "Dev Ready (10): Strat(5), Eng (1), Imp(3), Eme")
|
84
|
+
puts "task_id: #{task_id}"
|
85
|
+
end
|
86
|
+
day = result["value"]["day"]
|
87
|
+
milestone_metrics = (hash[end_milestone] ||= {})
|
88
|
+
milestone_metrics["current"] ||= 0
|
89
|
+
milestone_metrics["current"] += 1
|
90
|
+
elapsed_seconds = Time.now - Time.parse(day)
|
91
|
+
elapsed_days = ((elapsed_seconds / 60) / 60) / 24
|
92
|
+
milestone_metrics["current_days_total"] ||= 0
|
93
|
+
milestone_metrics["current_days_total"] += elapsed_days
|
94
|
+
elapsed_days_by_phase[end_milestone] ||= []
|
95
|
+
elapsed_days_by_phase[end_milestone].push elapsed_days
|
96
|
+
end
|
97
|
+
|
98
|
+
hash.each do |key, value|
|
99
|
+
if (value["current_days_total"] && value["current"])
|
100
|
+
value["current_days_average"] = value["current_days_total"] / value["current"]
|
101
|
+
all_elapsed_days = elapsed_days_by_phase[key]
|
102
|
+
value["current_days_stdev"] = all_elapsed_days.standard_deviation
|
103
|
+
end
|
104
|
+
end
|
105
|
+
|
106
|
+
if (params[:current_milestones_only])
|
107
|
+
hash = hash.select {|phase, phase_metrics| phase_metrics["current_days_average"] }
|
108
|
+
end
|
109
|
+
return hash.to_json
|
110
|
+
end
|
111
|
+
|
112
|
+
if (milestone = params[:milestone])
|
113
|
+
map_reduce_options[:query] = {"start_milestone" => milestone}
|
114
|
+
collection = db["milestone_times"]
|
115
|
+
else
|
116
|
+
collection = db["lead_times"]
|
117
|
+
end
|
118
|
+
|
119
|
+
results = collection.map_reduce(map_function, reduce_function, map_reduce_options)
|
120
|
+
|
121
|
+
results.find().map do |result|
|
122
|
+
[result['_id'], result["value"]["avg"]]
|
123
|
+
end.sort {|a, b| datestring_to_int(a[0]) <=> datestring_to_int(b[0])}.to_json
|
124
|
+
end
|
125
|
+
|
126
|
+
def datestring_to_int(datestring)
|
127
|
+
datestring.split("-").map {|part| part.length == 1 ? "0" + part : part}.join("").to_i
|
128
|
+
end
|
129
|
+
end
|
63
130
|
end
|
data/lib/asanban/version.rb
CHANGED
data/spec/service_spec.rb
CHANGED
@@ -1,6 +1,5 @@
|
|
1
1
|
ENV['RACK_ENV'] = 'test'
|
2
|
-
require 'asanban
|
3
|
-
require 'asanban/bulk_load'
|
2
|
+
require 'asanban'
|
4
3
|
require 'rspec'
|
5
4
|
require 'rack/test'
|
6
5
|
|
@@ -8,83 +7,176 @@ describe Asanban::Service do
|
|
8
7
|
include Rack::Test::Methods
|
9
8
|
|
10
9
|
before do
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
10
|
+
config = { "mongodb_uri" => "mongodb://localhost/", "mongodb_dbname" => "asana_test" }
|
11
|
+
app.settings.set(:config, config)
|
12
|
+
conn = Mongo::Connection.from_uri(config['mongodb_uri'])
|
13
|
+
db = conn.db(config["mongodb_dbname"])
|
14
|
+
@milestone_times_collection = db["milestone_times"]
|
15
|
+
@milestone_times_collection.drop
|
16
|
+
@lead_times_collection = db["lead_times"]
|
17
|
+
@lead_times_collection.drop
|
18
|
+
@story_id_counter = 1
|
19
|
+
|
20
|
+
class Time
|
21
|
+
@stub_time = Time.parse("2013-9-30")
|
22
|
+
def self.now()
|
23
|
+
@stub_time
|
24
|
+
end
|
25
|
+
end
|
19
26
|
end
|
20
27
|
|
21
28
|
it "returns average lead times by year" do
|
22
|
-
|
23
|
-
|
24
|
-
|
29
|
+
record_lead_time('Foo', "October 2, 2012", "October 14, 2012") #12 days
|
30
|
+
record_lead_time('Bar', "May 12, 2013", "May 14, 2013") #2 days
|
31
|
+
record_lead_time('Baz', "October 12, 2013", "October 16, 2013") #4 days
|
25
32
|
|
26
|
-
|
27
|
-
|
28
|
-
|
33
|
+
get '/metrics', :aggregate_by => 'year'
|
34
|
+
#Average: 12 days 2012, 3 days 2013
|
35
|
+
expect(last_response.body).to eq('[["2012",12.0],["2013",3.0]]')
|
29
36
|
end
|
30
37
|
|
31
38
|
it "returns average lead times by month" do
|
32
|
-
|
33
|
-
|
34
|
-
|
39
|
+
record_lead_time('Foo', "September 20, 2013", "September 30, 2013") #10 days
|
40
|
+
record_lead_time('Bar', "October 2, 2013", "October 14, 2013") #12 days
|
41
|
+
record_lead_time('Baz', "October 12, 2013", "October 16, 2013") #4 days
|
35
42
|
|
36
|
-
|
37
|
-
|
38
|
-
|
43
|
+
get '/metrics', :aggregate_by => 'month'
|
44
|
+
#Average: 10 days September, 8 days in October
|
45
|
+
expect(last_response.body).to eq('[["2013-9",10.0],["2013-10",8.0]]')
|
39
46
|
end
|
40
47
|
|
41
48
|
it "returns average lead times by day" do
|
42
|
-
|
43
|
-
|
44
|
-
|
49
|
+
record_lead_time('Foo', "October 2, 2013", "October 14, 2013") #12 days
|
50
|
+
record_lead_time('Bar', "October 12, 2013", "October 14, 2013") #2 days
|
51
|
+
record_lead_time('Baz', "October 12, 2013", "October 16, 2013") #4 days
|
45
52
|
|
46
|
-
|
47
|
-
|
48
|
-
|
53
|
+
get '/metrics', :aggregate_by => 'day'
|
54
|
+
#Average: 7 days 10/14, 4 days 10/16
|
55
|
+
expect(last_response.body).to eq('[["2013-10-14",7.0],["2013-10-16",4.0]]')
|
49
56
|
end
|
50
57
|
|
51
58
|
describe "milestone times recorded" do
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
59
|
+
before do
|
60
|
+
record_time('Foo', false, false, "September 1, 2013", "September 3, 2013", 'Dev Ready', 'Dev In Progress', @milestone_times_collection) #2
|
61
|
+
record_time('Foo', false, false, "September 3, 2013", "September 10, 2013", 'Dev In Progress', 'Dev Done', @milestone_times_collection) #7
|
62
|
+
record_time('Foo', false, false, "September 10, 2013", "September 15, 2013", 'Dev Done', 'PM Test, in Dev', @milestone_times_collection) #5
|
63
|
+
record_time('Bar', false, false, "September 1, 2013", "September 7, 2013", 'Dev Ready', 'Dev In Progress', @milestone_times_collection) #6
|
64
|
+
record_time('Bar', false, false, "September 7, 2013", "September 12, 2013", 'Dev In Progress', 'Dev Done', @milestone_times_collection) #5
|
65
|
+
record_time('Bar', false, false, "September 12, 2013", "September 15, 2013", 'Dev Done', 'PM Test, in Dev', @milestone_times_collection) #3
|
66
|
+
end
|
67
|
+
|
68
|
+
it "returns average time in 'Dev Ready' by month" do
|
69
|
+
get '/metrics', :aggregate_by => 'month', :milestone => 'Dev Ready'
|
70
|
+
expect(last_response.body).to eq('[["2013-9",4.0]]')
|
71
|
+
end
|
72
|
+
|
73
|
+
it "returns average time in 'Dev In Progress' by month" do
|
74
|
+
get '/metrics', :aggregate_by => 'month', :milestone => 'Dev In Progress'
|
75
|
+
expect(last_response.body).to eq('[["2013-9",6.0]]')
|
76
|
+
end
|
77
|
+
|
78
|
+
describe "cycle times" do
|
79
|
+
it "returns number of items currently in each phase" do
|
80
|
+
get '/metrics', :aggregate_by => 'start_milestone'
|
81
|
+
|
82
|
+
json = JSON(last_response.body)
|
83
|
+
expect(json["PM Test, in Dev"]["current"]).to eq(2.0)
|
84
|
+
end
|
85
|
+
|
86
|
+
it "filters out tasks that are completed" do
|
87
|
+
record_time('Baz', true, false, "September 1, 2013", "September 8, 2013", 'Dev Ready', 'Dev In Progress', @milestone_times_collection)
|
88
|
+
record_time('Baz', true, false, "September 8, 2013", "September 14, 2013", 'Dev In Progress', 'Dev Done', @milestone_times_collection)
|
89
|
+
record_time('Baz', true, false, "September 14, 2013", "September 19, 2013", 'Dev Done', 'PM Test, in Dev', @milestone_times_collection)
|
90
|
+
|
91
|
+
get '/metrics', :aggregate_by => 'start_milestone'
|
92
|
+
|
93
|
+
json = JSON(last_response.body)
|
94
|
+
expect(json["PM Test, in Dev"]["current"]).to eq(2.0)
|
95
|
+
end
|
96
|
+
|
97
|
+
it "filters out deleted tasks" do
|
98
|
+
record_time('Baz', false, true, "September 1, 2013", "September 8, 2013", 'Dev Ready', 'Dev In Progress', @milestone_times_collection)
|
99
|
+
record_time('Baz', false, true, "September 8, 2013", "September 14, 2013", 'Dev In Progress', 'Dev Done', @milestone_times_collection)
|
100
|
+
record_time('Baz', false, true, "September 14, 2013", "September 19, 2013", 'Dev Done', 'PM Test, in Dev', @milestone_times_collection)
|
101
|
+
|
102
|
+
get '/metrics', :aggregate_by => 'start_milestone'
|
103
|
+
|
104
|
+
json = JSON(last_response.body)
|
105
|
+
expect(json["PM Test, in Dev"]["current"]).to eq(2.0)
|
106
|
+
end
|
107
|
+
|
108
|
+
it "returns time in phase for items still in phase" do
|
109
|
+
record_time('Baz', false, false, "September 1, 2013", "September 8, 2013", 'Dev Ready', 'Dev In Progress', @milestone_times_collection)
|
110
|
+
record_time('Baz', false, false, "September 8, 2013", "September 14, 2013", 'Dev In Progress', 'Dev Done', @milestone_times_collection)
|
111
|
+
record_time('Baz', false, false, "September 14, 2013", "September 19, 2013", 'Dev Done', 'PM Test, in Dev', @milestone_times_collection)
|
112
|
+
|
113
|
+
get '/metrics', :aggregate_by => 'start_milestone'
|
114
|
+
|
115
|
+
json = JSON(last_response.body)
|
116
|
+
json["PM Test, in Dev"]["current_days_average"].should be_within(0.01).of(13.66)
|
117
|
+
end
|
118
|
+
|
119
|
+
it "returns standard deviation of time in phase for items still in phase" do
|
120
|
+
record_time('Baz', false, false, "September 1, 2013", "September 8, 2013", 'Dev Ready', 'Dev In Progress', @milestone_times_collection)
|
121
|
+
record_time('Baz', false, false, "September 8, 2013", "September 14, 2013", 'Dev In Progress', 'Dev Done', @milestone_times_collection)
|
122
|
+
record_time('Baz', false, false, "September 14, 2013", "September 19, 2013", 'Dev Done', 'PM Test, in Dev', @milestone_times_collection)
|
123
|
+
|
124
|
+
get '/metrics', :aggregate_by => 'start_milestone'
|
125
|
+
|
126
|
+
json = JSON(last_response.body)
|
127
|
+
json["PM Test, in Dev"]["current_days_stdev"].should be_within(0.01).of(1.88)
|
128
|
+
end
|
129
|
+
|
130
|
+
it "filters out phases with no current items" do
|
131
|
+
get '/metrics', :aggregate_by => 'start_milestone', :current_milestones_only => true
|
132
|
+
json = JSON(last_response.body)
|
133
|
+
json["Dev Ready"].should be_nil
|
134
|
+
end
|
135
|
+
|
136
|
+
it "returns number of items that flowed through each phase" do
|
137
|
+
get '/metrics', :aggregate_by => 'start_milestone'
|
138
|
+
json = JSON(last_response.body)
|
139
|
+
expect(json["Dev Ready"]["count"]).to eq(2.0)
|
140
|
+
end
|
141
|
+
|
142
|
+
it "returns cycle time for each phase" do
|
143
|
+
get '/metrics', :aggregate_by => 'start_milestone'
|
144
|
+
|
145
|
+
json = JSON(last_response.body)
|
146
|
+
expect(json["Dev Ready"]["cycle_time"]).to eq(4.0)
|
147
|
+
end
|
148
|
+
|
149
|
+
it "filters by start and end date" do
|
150
|
+
record_time('Baz', false, false, "September 1, 2013", "September 8, 2013", 'Dev Ready', 'Dev In Progress', @milestone_times_collection)
|
151
|
+
record_time('Baz', false, false, "September 8, 2013", "September 14, 2013", 'Dev In Progress', 'Dev Done', @milestone_times_collection)
|
152
|
+
record_time('Baz', false, false, "September 14, 2013", "September 19, 2013", 'Dev Done', 'PM Test, in Dev', @milestone_times_collection)
|
153
|
+
|
154
|
+
get '/metrics', :aggregate_by => 'start_milestone', :start_date => '2013-9-12', :end_date => '2013-9-17'
|
155
|
+
|
156
|
+
json = JSON(last_response.body)
|
157
|
+
expect(json["PM Test, in Dev"]["current"]).to eq(2.0)
|
158
|
+
#TODO: check cycle time
|
159
|
+
end
|
160
|
+
end
|
161
|
+
end
|
162
|
+
|
163
|
+
it "returns an error when an invalid aggregation is specified" do
|
164
|
+
get '/metrics', :aggregate_by => 'engineer'
|
74
165
|
expect(last_response.status).to eq(400)
|
75
|
-
|
166
|
+
end
|
76
167
|
|
77
168
|
def app
|
78
169
|
Asanban::Service
|
79
170
|
end
|
80
171
|
|
81
172
|
def record_lead_time(task_id, started_at, ended_at)
|
82
|
-
|
173
|
+
record_time(task_id, false, false, started_at, ended_at, 'Dev Ready', 'Production', @lead_times_collection)
|
83
174
|
end
|
84
175
|
|
85
|
-
def record_time(task_id, started_at, ended_at, start_milestone, end_milestone, collection)
|
86
|
-
|
87
|
-
|
88
|
-
|
176
|
+
def record_time(task_id, completed, deleted, started_at, ended_at, start_milestone, end_milestone, collection)
|
177
|
+
start_story = {'id' => @story_id_counter, 'created_at' => started_at}
|
178
|
+
@story_id_counter += 1
|
179
|
+
end_story = {'id' => @story_id_counter, 'created_at' => ended_at}
|
180
|
+
Asanban::BulkLoad.record_time(task_id, completed, deleted, start_story, start_milestone, end_story, end_milestone, collection)
|
89
181
|
end
|
90
182
|
end
|
@@ -0,0 +1,44 @@
|
|
1
|
+
<html ng-app="asanbanApp">
|
2
|
+
<head>
|
3
|
+
<link href="https://public.bizo.com/bizstrap/css/bizstrap-v2.2.2.15.css" rel="stylesheet" />
|
4
|
+
<script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.3.0-beta.6/angular.min.js"></script>
|
5
|
+
<script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.3.0-beta.6/angular-resource.min.js"></script>
|
6
|
+
<script src="js/controllers.js"></script>
|
7
|
+
</head>
|
8
|
+
<body ng-controller="AsanbanDashboardCtrl">
|
9
|
+
<h1 class="section-header">Kanban Dashboard</h1>
|
10
|
+
<div class="control-group">
|
11
|
+
<label class="control-label">Analysis Begin Date</label>
|
12
|
+
<div class="controls">
|
13
|
+
<input type="text" class="input-xlarge" ng-model="start_date" />
|
14
|
+
</div>
|
15
|
+
</div>
|
16
|
+
<div class="control-group">
|
17
|
+
<label class="control-label">Analysis End Date</label>
|
18
|
+
<div class="controls">
|
19
|
+
<input type="text" class="input-xlarge" ng-model="end_date" />
|
20
|
+
</div>
|
21
|
+
</div>
|
22
|
+
<button class="btn btn-large" ng-click="query()">Filter</button>
|
23
|
+
<table class="table">
|
24
|
+
<thead>
|
25
|
+
<tr>
|
26
|
+
<th>Phase</th>
|
27
|
+
<th>Total # of Items</th>
|
28
|
+
<th>Average # of Days in State</th>
|
29
|
+
<th>Std Deviation of Days in State</th>
|
30
|
+
<th># of Items that Flowed Through in Date Range</th>
|
31
|
+
</tr>
|
32
|
+
</thead>
|
33
|
+
<tbody>
|
34
|
+
<tr ng-repeat="(phase, phase_metrics) in metrics">
|
35
|
+
<td>{{phase}}</td>
|
36
|
+
<td>{{phase_metrics.current | number}}</td>
|
37
|
+
<td>{{phase_metrics.current_days_average | number:0}}</td>
|
38
|
+
<td>{{phase_metrics.current_days_stdev | number:1}}</td>
|
39
|
+
<td>{{phase_metrics.count | number}}</td>
|
40
|
+
</tr>
|
41
|
+
</tbody>
|
42
|
+
</table>
|
43
|
+
</body>
|
44
|
+
</html>
|
@@ -0,0 +1,28 @@
|
|
1
|
+
var asanbanApp = angular.module('asanbanApp', ['ngResource']);
|
2
|
+
|
3
|
+
//TODO: Move this into a separate file
|
4
|
+
asanbanApp.factory('Metrics', ['$resource',
|
5
|
+
function($resource){
|
6
|
+
return $resource('/metrics?aggregate_by=start_milestone¤t_milestones_only=true&start_date=:start_date&end_date=:end_date',
|
7
|
+
{}, {
|
8
|
+
query: {method:'GET'}
|
9
|
+
});
|
10
|
+
}]);
|
11
|
+
|
12
|
+
asanbanApp.controller('AsanbanDashboardCtrl', ['$scope', 'Metrics',
|
13
|
+
function($scope, Metrics) {
|
14
|
+
var format_date = function(date) {
|
15
|
+
return date.toISOString().substring(0, 10);
|
16
|
+
};
|
17
|
+
|
18
|
+
var start_date = new Date();
|
19
|
+
start_date.setMonth(start_date.getMonth() - 2);
|
20
|
+
$scope.start_date = format_date(start_date);
|
21
|
+
$scope.end_date = format_date(new Date());
|
22
|
+
|
23
|
+
$scope.query = function() {
|
24
|
+
$scope.metrics = Metrics.query({start_date: $scope.start_date, end_date: $scope.end_date});
|
25
|
+
};
|
26
|
+
|
27
|
+
$scope.query();
|
28
|
+
}]);
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: asanban
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.0.
|
4
|
+
version: 0.0.5
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Pat Gannon
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date:
|
11
|
+
date: 2014-04-30 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: mongo
|
@@ -66,6 +66,20 @@ dependencies:
|
|
66
66
|
- - ! '>='
|
67
67
|
- !ruby/object:Gem::Version
|
68
68
|
version: '0'
|
69
|
+
- !ruby/object:Gem::Dependency
|
70
|
+
name: descriptive_statistics
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
72
|
+
requirements:
|
73
|
+
- - ! '>='
|
74
|
+
- !ruby/object:Gem::Version
|
75
|
+
version: '0'
|
76
|
+
type: :runtime
|
77
|
+
prerelease: false
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
79
|
+
requirements:
|
80
|
+
- - ! '>='
|
81
|
+
- !ruby/object:Gem::Version
|
82
|
+
version: '0'
|
69
83
|
- !ruby/object:Gem::Dependency
|
70
84
|
name: rspec
|
71
85
|
requirement: !ruby/object:Gem::Requirement
|
@@ -116,6 +130,8 @@ files:
|
|
116
130
|
- lib/asanban/service.rb
|
117
131
|
- lib/asanban/version.rb
|
118
132
|
- spec/service_spec.rb
|
133
|
+
- static/dashboard.html
|
134
|
+
- static/js/controllers.js
|
119
135
|
- static/metrics.html
|
120
136
|
homepage: ''
|
121
137
|
licenses: []
|