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 CHANGED
@@ -1,15 +1,15 @@
1
1
  ---
2
2
  !binary "U0hBMQ==":
3
3
  metadata.gz: !binary |-
4
- MWQxYzY4OTJkMzJlOTdiMWQwMDE4MzM0NWFhODQxOWMwZWJkZmY1Yg==
4
+ Y2VmZjRhNTI3ZGE1OTUyZjYzYWE1OTliNDU1MmFlZWViNWU0OGIwMQ==
5
5
  data.tar.gz: !binary |-
6
- MmY0MTYzM2RmYzNiNGQ1MzlmNWQwMjViOGUxYTg2YWQyYmRhNDZlNg==
6
+ MTkxMjA5OGQzMGM5NzBlODFiNjMyNmE2MDdmNDI2OTA2MTVlYzAwYQ==
7
7
  SHA512:
8
8
  metadata.gz: !binary |-
9
- NzVkZWNiMjIzYzQ5YzRiYjI2NjA5ZWU0YzhmMjJmYjk2MWMzZTliOGZmMTIx
10
- ZjYxYzY2MzVjYTk3YzgyNmNiYzEzNWI2NDA3NGY5NzdmNWFmYzZiMGE5YWY5
11
- ODIzMTRjNzkyYjIxNDBlMTgyMTlkMzdiM2NhZmUzNzJhZjk1ZTg=
9
+ ZjRhMTdkYjdhYmNmNTFkYzJkM2NmYjIwY2M1MjE5ZmVhNzFiMThlODc4M2Zi
10
+ MWMyODJjNjYyY2I4MzY3NjY2ZjJlMTJjMDJkNGI5ZjgxNDBiNjU1ZDY0N2Ni
11
+ YzAyMTU5OTBlZmJjYTExNDg3MWY1YzAzY2YxZTM5MmRlYjEwM2I=
12
12
  data.tar.gz: !binary |-
13
- OWU5NTlmOWRkZjI4Y2I1NWI1MTczYTg3NWI5NGUzN2JlMzgwMjMwYjMyNDM0
14
- ZjY5MWI5NTM4YTRkY2M2ZGQzYjRjMDBhYTRiZDdjZjlkNzVjOTVmYjViZDhk
15
- N2JmMmYwNTliZTI0MjM4YzY2ZGM3NDlkZjgzYjgyOWEzYmE3YjM=
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
@@ -1,5 +1,6 @@
1
1
  require "asanban/version"
2
+ require "asanban/service"
3
+ require "asanban/bulk_load"
2
4
 
3
5
  module Asanban
4
- # Your code goes here...
5
6
  end
@@ -6,156 +6,166 @@ require "time"
6
6
  require "yaml"
7
7
 
8
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']
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
- 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
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
- db = conn.db(mongodb_dbname)
45
- tasks_collection = db["tasks"]
44
+ db = conn.db(mongodb_dbname)
45
+ tasks_collection = db["tasks"]
46
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)
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
- 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)
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
- if task['errors']
72
- puts "Server returned an error retrieving stories: #{task['errors'][0]['message']}"
73
- else
74
- task["stories"] = stories['data']
75
- end
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
- tasks_collection.update({"id" => task['id']}, task, {:upsert => true})
78
- puts "Created task: #{task['id']}"
78
+ task["old"] = false
79
+ tasks_collection.update({"id" => task['id']}, task, {:upsert => true})
80
+ puts "Created task: #{task['id']}"
79
81
 
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
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
- puts "Done creating times."
87
- end
88
- end
88
+ puts "Done creating times."
89
+ end
90
+ end
89
91
 
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"]
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
- 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
+ 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
- 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
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
- 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
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
- #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
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
- 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
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
@@ -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
- class Service < Sinatra::Base
9
- set :public_folder, File.expand_path(File.join('..', '..', '..', 'static'), __FILE__)
10
- set :port, ENV['PORT'] if ENV['PORT'] #set by Heroku
11
-
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'))
18
- end
19
-
20
- get '/metrics' do
21
- config = settings.config
22
- conn = Mongo::Connection.from_uri(config['mongodb_uri'])
23
- db = conn.db(config["mongodb_dbname"])
24
- aggregate_by = params[:aggregate_by]
25
- return [400, "Cannot aggregate by #{aggregate_by}"] unless ["year", "month", "day"].include? aggregate_by
26
-
27
- map_function = "function() { emit(this.#{aggregate_by}, { count: 1, elapsed_days: this.elapsed_days }); };"
28
-
29
- reduce_function = "function (name, values){
30
- var n = {count : 0, elapsed_days : 0};
31
- for ( var i=0; i<values.length; i++ ){
32
- n.count += values[i].count;
33
- n.elapsed_days += values[i].elapsed_days;
34
- }
35
- return n;
36
- };"
37
-
38
- finalize_function = "function(who, res){
39
- res.avg = res.elapsed_days / res.count;
40
- return res;
41
- };"
42
-
43
- map_reduce_options = {:finalize => finalize_function, :out => "mr_results"}
44
- if (milestone = params[:milestone])
45
- map_reduce_options[:query] = {"start_milestone" => milestone}
46
- collection = db["milestone_times"]
47
- else
48
- collection = db["lead_times"]
49
- end
50
-
51
- results = collection.map_reduce(map_function, reduce_function, map_reduce_options)
52
-
53
- content_type :json
54
- results.find().map do |result|
55
- [result['_id'], result["value"]["avg"]]
56
- end.sort {|a, b| datestring_to_int(a[0]) <=> datestring_to_int(b[0])}.to_json
57
- end
58
-
59
- def datestring_to_int(datestring)
60
- datestring.split("-").map {|part| part.length == 1 ? "0" + part : part}.join("").to_i
61
- end
62
- end
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
@@ -1,3 +1,3 @@
1
1
  module Asanban
2
- VERSION = "0.0.4"
2
+ VERSION = "0.0.5"
3
3
  end
data/spec/service_spec.rb CHANGED
@@ -1,6 +1,5 @@
1
1
  ENV['RACK_ENV'] = 'test'
2
- require 'asanban/service'
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
- 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
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
- 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
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
- 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]]')
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
- 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
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
- 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]]')
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
- 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
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
- 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]]')
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
- 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'
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
- end
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
- record_time(task_id, started_at, ended_at, 'Dev Ready', 'Production', @lead_times_collection)
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
- 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)
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&current_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
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: 2013-10-30 00:00:00.000000000 Z
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: []