asanban 0.0.4 → 0.0.5
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 +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: []
|