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 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: []