shiftzilla 0.2.6

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,28 @@
1
+ require 'shiftzilla/milestone'
2
+
3
+ module Shiftzilla
4
+ class Milestones
5
+ def initialize(msrc)
6
+ @milestones = {}
7
+ msrc.each do |key,val|
8
+ @milestones[key.to_sym] = Shiftzilla::Milestone.new(val)
9
+ end
10
+ end
11
+
12
+ def start
13
+ @milestones[:start]
14
+ end
15
+
16
+ def feature_complete
17
+ @milestones[:feature_complete]
18
+ end
19
+
20
+ def code_freeze
21
+ @milestones[:code_freeze]
22
+ end
23
+
24
+ def ga
25
+ @milestones[:ga]
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,332 @@
1
+ require 'json'
2
+ require 'shiftzilla/bug'
3
+ require 'shiftzilla/helpers'
4
+ require 'shiftzilla/team_data'
5
+
6
+ include Shiftzilla::Helpers
7
+
8
+ module Shiftzilla
9
+ class OrgData
10
+ attr_reader :tmp_dir
11
+
12
+ def initialize(config)
13
+ @config = config
14
+ @teams = config.teams
15
+ @releases = config.releases
16
+ @tmp_dir = Shiftzilla::Helpers.tmp_dir
17
+ @org_data = { '_overall' => Shiftzilla::TeamData.new('_overall') }
18
+ end
19
+
20
+ def populate_releases
21
+ all_snapshots.each do |snapshot|
22
+ snapdate = Date.parse(snapshot)
23
+ next if snapdate < @config.earliest_milestone
24
+ break if snapdate > @config.latest_milestone
25
+ break if snapdate > Date.today
26
+
27
+ dbh.execute(all_bugs_query(snapshot)) do |row|
28
+ bzid = row[0].strip
29
+ comp = row[1].strip
30
+ tgtr = row[2].strip
31
+ owns = row[3].strip
32
+ stat = row[4].strip
33
+ summ = row[5].strip
34
+ keyw = row[6].nil? ? '' : row[6].strip
35
+ pmsc = row[7].nil? ? '0' : row[7].strip
36
+ cust = row[8].nil? ? 0 : row[8]
37
+
38
+ # Package up bug data
39
+ binfo = {
40
+ :snapdate => snapdate,
41
+ :test_blocker => keyw.include?('TestBlocker'),
42
+ :ops_blocker => keyw.include?('OpsBlocker'),
43
+ :owner => owns,
44
+ :summary => summ,
45
+ :component => comp,
46
+ :pm_score => pmsc,
47
+ :cust_cases => (cust == 1),
48
+ :tgt_release => tgtr,
49
+ }
50
+
51
+ tgt_release = @config.release_by_target(tgtr)
52
+ all_release = @config.release('All')
53
+
54
+ # If this component isn't mapped to a team, stub out a fake team.
55
+ tname = comp_map.has_key?(comp) ? comp_map[comp] : "(?) #{comp}"
56
+ unless @org_data.has_key?(tname)
57
+ @config.add_ad_hoc_team({ 'name' => tname, 'components' => [comp] })
58
+ @org_data[tname] = Shiftzilla::TeamData.new(tname)
59
+ end
60
+
61
+ team_rdata = tgt_release.nil? ? nil : @org_data[tname].get_release_data(tgt_release)
62
+ team_adata = @org_data[tname].get_release_data(all_release)
63
+ over_rdata = tgt_release.nil? ? nil : @org_data['_overall'].get_release_data(tgt_release)
64
+ over_adata = @org_data['_overall'].get_release_data(all_release)
65
+
66
+ # Do some bean counting
67
+ [over_rdata,team_rdata,over_adata,team_adata].each do |group|
68
+ next if group.nil?
69
+ snapdata = group.get_snapdata(snapshot)
70
+ if group.first_snap.nil?
71
+ group.first_snap = snapshot
72
+ group.first_snapdate = snapdate
73
+ end
74
+ group.latest_snap = snapshot
75
+ group.latest_snapdate = snapdate
76
+
77
+ bug = group.add_or_update_bug(bzid,binfo)
78
+
79
+ # Add info to the snapshot
80
+ snapdata.bug_ids << bzid
81
+ if bug.test_blocker or bug.ops_blocker
82
+ snapdata.tb_ids << bzid
83
+ end
84
+ if bug.cust_cases
85
+ snapdata.cc_ids << bzid
86
+ end
87
+ end
88
+ end
89
+
90
+ # Determine new and closed bug counts by comparing the current
91
+ # snapshot to the previous snapshot
92
+ @org_data.each do |tname,tdata|
93
+ @releases.each do |release|
94
+ next unless tdata.has_release_data?(release)
95
+
96
+ rdata = tdata.get_release_data(release)
97
+ next unless rdata.has_snapdata?(snapshot)
98
+
99
+ if rdata.prev_snap.nil?
100
+ rdata.prev_snap = snapshot
101
+ next
102
+ end
103
+
104
+ currdata = rdata.get_snapdata(snapshot)
105
+ prevdata = rdata.get_snapdata(rdata.prev_snap)
106
+
107
+ prev_bzids = prevdata.bug_ids
108
+ curr_bzids = currdata.bug_ids
109
+ prev_tbids = prevdata.tb_ids
110
+ curr_tbids = currdata.tb_ids
111
+ prev_ccids = prevdata.cc_ids
112
+ curr_ccids = currdata.cc_ids
113
+ currdata.closed_bugs = prev_bzids.select{ |bzid| not curr_bzids.include?(bzid) }.length
114
+ currdata.new_bugs = curr_bzids.select{ |bzid| not prev_bzids.include?(bzid) }.length
115
+ currdata.closed_tb = prev_tbids.select{ |tbid| not curr_tbids.include?(tbid) }.length
116
+ currdata.new_tb = curr_tbids.select{ |tbid| not prev_tbids.include?(tbid) }.length
117
+ currdata.closed_cc = prev_ccids.select{ |ccid| not curr_ccids.include?(ccid) }.length
118
+ currdata.new_cc = curr_ccids.select{ |ccid| not prev_ccids.include?(ccid) }.length
119
+
120
+ rdata.prev_snap = snapshot
121
+ end
122
+ end
123
+ end
124
+ @all_teams = @teams.map{ |t| t.name }.concat(@org_data.keys).uniq
125
+ @ordered_teams = ['_overall'].concat(@all_teams.select{ |t| t != '_overall'}.sort)
126
+ end
127
+
128
+ def get_ordered_teams
129
+ @ordered_teams
130
+ end
131
+
132
+ def get_release_data(team_name,release)
133
+ if @org_data.has_key?(team_name) and @org_data[team_name].has_release_data?(release)
134
+ return @org_data[team_name].get_release_data(release)
135
+ end
136
+ return nil
137
+ end
138
+
139
+ def build_series
140
+ @team_files = []
141
+ @ordered_teams.each do |tname|
142
+ unless @org_data.has_key?(tname)
143
+ @org_data[tname] = Shiftzilla::TeamData.new(tname)
144
+ end
145
+ tdata = @org_data[tname]
146
+ tinfo = @config.team(tname)
147
+
148
+ @team_files << {
149
+ :tname => tdata.title,
150
+ :file => tdata.file,
151
+ :releases => {},
152
+ }
153
+
154
+ @releases.each do |release|
155
+ rname = release.name
156
+ bug_total = 0
157
+ unless tdata.has_release_data?(release)
158
+ @team_files[-1][:releases][release.name] = bug_total
159
+ next
160
+ end
161
+ rdata = tdata.get_release_data(release)
162
+ if rdata.snaps.has_key?(latest_snapshot)
163
+ bug_total = rdata.snaps[latest_snapshot].total_bugs
164
+ end
165
+ @team_files[-1][:releases][release.name] = bug_total
166
+
167
+ next if rdata.first_snap.nil? and not release.uses_milestones?
168
+ next if release.built_in?
169
+
170
+ rdata.populate_series
171
+ end
172
+ end
173
+ end
174
+
175
+ def generate_reports
176
+ build_time = timestamp
177
+ all_release = @config.release('All')
178
+ @ordered_teams.each do |tname|
179
+ tinfo = @config.team(tname)
180
+ tdata = @org_data[tname]
181
+
182
+ team_pinfo = {
183
+ :build_time => build_time,
184
+ :tname => tdata.title,
185
+ :tinfo => tinfo,
186
+ :tdata => tdata,
187
+ :team_files => @team_files,
188
+ :bug_url => BZ_URL,
189
+ :chart_order => ((1..(@releases.length - 1)).to_a << 0),
190
+ :releases => [],
191
+ :latest_snapshot => latest_snapshot,
192
+ :all_bugs => [],
193
+ }
194
+
195
+ @releases.each do |release|
196
+ rname = release.name
197
+ rdata = tdata.has_release_data?(release) ? tdata.get_release_data(release) : nil
198
+ snapdata = nil
199
+ if not rdata.nil? and rdata.snaps.has_key?(latest_snapshot)
200
+ snapdata = rdata.snaps[latest_snapshot]
201
+ else
202
+ snapdata = Shiftzilla::SnapData.new(latest_snapshot)
203
+ end
204
+
205
+ release_info = {
206
+ :release => release,
207
+ :snapdata => snapdata,
208
+ :no_rdata => (rdata.nil? ? true : false),
209
+ :bug_avg_age => (rdata.nil? ? 0 : rdata.bug_avg_age),
210
+ :tb_avg_age => (rdata.nil? ? 0 : rdata.tb_avg_age),
211
+ }
212
+ unless rdata.nil?
213
+ release_info[:charts] = {
214
+ :burndown => chartify('Bug Burndown',rdata.series[:date],[
215
+ {
216
+ :label => 'Ideal Trend',
217
+ :data => rdata.series[:ideal],
218
+ },
219
+ {
220
+ :label => 'Total',
221
+ :data => rdata.series[:total_bugs],
222
+ },
223
+ {
224
+ :label => 'w/ Customer Cases',
225
+ :data => rdata.series[:total_cc],
226
+ },
227
+ ]),
228
+ :new_closed => chartify('New vs. Closed',rdata.series[:date],[
229
+ {
230
+ :label => 'New',
231
+ :data => rdata.series[:new_bugs],
232
+ },
233
+ {
234
+ :label => 'Closed',
235
+ :data => rdata.series[:closed_bugs],
236
+ },
237
+ ]),
238
+ :blockers => chartify('Test / Ops Blockers',rdata.series[:date],[
239
+ {
240
+ :label => 'Total',
241
+ :data => rdata.series[:total_tb],
242
+ },
243
+ {
244
+ :label => 'New',
245
+ :data => rdata.series[:new_tb],
246
+ },
247
+ {
248
+ :label => 'Closed',
249
+ :data => rdata.series[:closed_tb],
250
+ },
251
+ ]),
252
+ }
253
+ end
254
+ team_pinfo[:releases] << release_info
255
+
256
+ if rname == 'All'
257
+ team_pinfo[:all_bugs] = snapdata.bug_ids.map{ |id| rdata.bugs[id] }
258
+ end
259
+ end
260
+ team_page = haml_engine.render(Object.new,team_pinfo)
261
+ File.write(File.join(@tmp_dir,tdata.file), team_page)
262
+ end
263
+ # Copy flot library to build area
264
+ jsdir = File.join(@tmp_dir,'js')
265
+ Dir.mkdir(jsdir)
266
+ FileUtils.cp(File.join(VENDOR_DIR,'flot','jquery.flot.min.js'),jsdir)
267
+ end
268
+
269
+ def show_local_reports
270
+ system("open file://#{@tmp_dir}/index.html")
271
+ end
272
+
273
+ def publish_reports(ssh)
274
+ system("ssh #{ssh[:host]} 'rm -rf #{ssh[:path]}/*'")
275
+ system("rsync -avPq #{@tmp_dir}/* #{ssh[:host]}:#{ssh[:path]}/")
276
+ end
277
+
278
+ private
279
+
280
+ def comp_map
281
+ @comp_map ||= begin
282
+ comp_map = {}
283
+ @teams.each do |team|
284
+ team.components.each do |comp|
285
+ comp_map[comp] = team.name
286
+ end
287
+ end
288
+ comp_map
289
+ end
290
+ end
291
+
292
+ def chartify(title,dates,series)
293
+ modulo = label_modulo(dates.length)
294
+ tlist = []
295
+ dates.each_with_index do |val,idx|
296
+ next unless (idx + 1) % modulo == 0
297
+ tlist << [idx,Date.parse(val).strftime('%m/%d')]
298
+ end
299
+ data = []
300
+ series.each do |s|
301
+ dlist = []
302
+ s[:data].each_with_index do |val,idx|
303
+ dlist <<[idx,val]
304
+ end
305
+ data << { :label => s[:label], :data => dlist }
306
+ end
307
+ options = {
308
+ :xaxis => {
309
+ :ticks => tlist,
310
+ },
311
+ :yaxis => {
312
+ :min => 0,
313
+ :labelWidth => 50,
314
+ },
315
+ :tickSize => 5,
316
+ }
317
+ return { :title => title, :data => data.to_json, :options => options.to_json }
318
+ end
319
+
320
+ def label_modulo(date_count)
321
+ if date_count < 15
322
+ return 1
323
+ elsif date_count < 30
324
+ return 2
325
+ elsif date_count < 60
326
+ return 5
327
+ else
328
+ return 10
329
+ end
330
+ end
331
+ end
332
+ end
@@ -0,0 +1,34 @@
1
+ require 'shiftzilla/milestones'
2
+
3
+ module Shiftzilla
4
+ class Release
5
+ attr_reader :name, :targets, :milestones, :default, :token
6
+
7
+ def initialize(release,builtin=false)
8
+ @name = release['name']
9
+ @token = @name.tr(' .', '_')
10
+ @targets = release['targets']
11
+ @default = release.has_key?('default') ? release['default'] : false
12
+ @builtin = builtin
13
+ @milestones = nil
14
+ if release.has_key?('milestones')
15
+ @milestones = Shiftzilla::Milestones.new(release['milestones'])
16
+ end
17
+ end
18
+
19
+ def uses_milestones?
20
+ return @milestones.nil? ? false : true
21
+ end
22
+
23
+ def built_in?
24
+ @builtin
25
+ end
26
+
27
+ def no_tgt_rel?
28
+ if @targets.length == 1 and @targets[0] == '---'
29
+ return true
30
+ end
31
+ false
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,185 @@
1
+ require 'shiftzilla/bug'
2
+ require 'shiftzilla/snap_data'
3
+
4
+ module Shiftzilla
5
+ class ReleaseData
6
+ attr_accessor :snaps, :bugs, :prev_snap, :latest_snap, :latest_snapdate, :first_snap, :first_snapdate, :labels, :series
7
+
8
+ def initialize(release)
9
+ @release = release
10
+ @snaps = {}
11
+ @bugs = {}
12
+ @prev_snap = nil
13
+ @latest_snap = nil
14
+ @latest_snapdate = nil
15
+ @first_snap = nil
16
+ @first_snapdate = nil
17
+ @labels = {}
18
+ @series = {
19
+ :date => [],
20
+ :ideal => [],
21
+ :total_bugs => [],
22
+ :new_bugs => [],
23
+ :closed_bugs => [],
24
+ :total_tb => [],
25
+ :new_tb => [],
26
+ :closed_tb => [],
27
+ :total_cc => [],
28
+ :new_cc => [],
29
+ :closed_cc => [],
30
+ }
31
+ end
32
+
33
+ def max_total
34
+ pick_max([@snaps.values.map{ |s| s.total_bugs }])
35
+ end
36
+
37
+ def max_new_closed
38
+ pick_max([@snaps.values.map{ |s| s.new_bugs },
39
+ @snaps.values.map{ |s| s.closed_bugs }
40
+ ])
41
+ end
42
+
43
+ def max_tb
44
+ pick_max([@snaps.values.map{ |s| s.total_tb },
45
+ @snaps.values.map{ |s| s.new_tb },
46
+ @snaps.values.map{ |s| s.closed_tb }
47
+ ])
48
+ end
49
+
50
+ def max_cc
51
+ pick_max([@snaps.values.map{ |s| s.total_cc },
52
+ @snaps.values.map{ |s| s.new_cc },
53
+ @snaps.values.map{ |s| s.closed_cc }
54
+ ])
55
+ end
56
+
57
+ def populate_series
58
+ return if first_snap.nil?
59
+
60
+ # Set up the 'ideal' series
61
+ ideal_slope = max_total.to_f / burndown_span.to_f
62
+ ideal_total = max_total
63
+
64
+ range_idx = 0
65
+ series_range.each do |date|
66
+ next if date.saturday? or date.sunday?
67
+ snapshot = date.strftime('%Y-%m-%d')
68
+
69
+ @series[:date] << snapshot
70
+ @series[:ideal] << ideal_total
71
+ ideal_total = ideal_total < ideal_slope ? 0 : ideal_total - ideal_slope
72
+
73
+ snapdata = @snaps.has_key?(snapshot) ? @snaps[snapshot] : nil
74
+ if date < first_snapdate
75
+ snapdata = @snaps[first_snap]
76
+ end
77
+
78
+ ['bugs','tb','cc'].each do |set|
79
+ ['total','new','closed'].each do |count|
80
+ set_key = "#{count}_#{set}".to_sym
81
+ @series[set_key] << (snapdata.nil? ? nil : snapdata.send(set_key))
82
+ end
83
+ end
84
+
85
+ if range_idx % label_modulo == 0
86
+ @labels[range_idx] = date.strftime('%m/%d')
87
+ end
88
+ range_idx += 1
89
+ end
90
+ end
91
+
92
+ def series_range
93
+ if @release.uses_milestones?
94
+ return (@release.milestones.start.date..@release.milestones.ga.date)
95
+ elsif not @first_snapdate.nil? and not @latest_snapdate.nil?
96
+ return (@first_snapdate..@latest_snapdate)
97
+ end
98
+ return nil
99
+ end
100
+
101
+ def series_span
102
+ if @release.uses_milestones?
103
+ return business_days_between(@release.milestones.start.date,@release.milestones.ga.date)
104
+ elsif not @first_snapdate.nil? and not @latest_snapdate.nil?
105
+ return business_days_between(@first_snapdate,@latest_snapdate)
106
+ end
107
+ return nil
108
+ end
109
+
110
+ def burndown_span
111
+ if @release.uses_milestones?
112
+ return business_days_between(@release.milestones.start.date,@release.milestones.code_freeze.date) - 2
113
+ elsif not @first_snapdate.nil? and not @latest_snapdate.nil?
114
+ return business_days_between(@first_snapdate,@latest_snapdate)
115
+ end
116
+ return nil
117
+ end
118
+
119
+ def bug_avg_age(blockers_only=false)
120
+ bug_list = blockers_only ? @bugs.values.select{ |b| b.test_blocker } : @bugs.values
121
+ bug_count = bug_list.length
122
+ age_total = bug_list.map{ |b| b.age }.sum
123
+ return bug_count == 0 ? 0 : (age_total / bug_count).round(1).to_s
124
+ end
125
+
126
+ def tb_avg_age
127
+ bug_avg_age(true)
128
+ end
129
+
130
+ def has_snapdata?(snapshot)
131
+ @snaps.has_key?(snapshot)
132
+ end
133
+
134
+ def get_snapdata(snapshot)
135
+ unless @snaps.has_key?(snapshot)
136
+ @snaps[snapshot] = Shiftzilla::SnapData.new(snapshot)
137
+ end
138
+ @snaps[snapshot]
139
+ end
140
+
141
+ def add_or_update_bug(bzid,binfo)
142
+ unless @bugs.has_key?(bzid)
143
+ @bugs[bzid] = Shiftzilla::Bug.new(bzid,binfo)
144
+ else
145
+ @bugs[bzid].update(binfo)
146
+ end
147
+ @bugs[bzid]
148
+ end
149
+
150
+ private
151
+
152
+ # Hat tip to https://stackoverflow.com/a/24753003 for this:
153
+ def business_days_between(start_date, end_date)
154
+ days_between = (end_date - start_date).to_i
155
+ return 0 unless days_between > 0
156
+ whole_weeks, extra_days = days_between.divmod(7)
157
+ unless extra_days.zero?
158
+ extra_days -= if (start_date + 1).wday <= end_date.wday
159
+ [(start_date + 1).sunday?, end_date.saturday?].count(true)
160
+ else
161
+ 2
162
+ end
163
+ end
164
+ (whole_weeks * 5) + extra_days
165
+ end
166
+
167
+ def pick_max(series_list)
168
+ max_val = 0
169
+ series_list.each do |s|
170
+ smax = s.select{ |v| not v.nil? }.max
171
+ next if smax.nil?
172
+ next unless smax > max_val
173
+ max_val = smax
174
+ end
175
+ return max_val
176
+ end
177
+
178
+ def label_modulo
179
+ if series_span < 50
180
+ return 5
181
+ end
182
+ return 10
183
+ end
184
+ end
185
+ end
@@ -0,0 +1,31 @@
1
+ module Shiftzilla
2
+ class SnapData
3
+ attr_reader :id
4
+ attr_accessor :bug_ids, :tb_ids, :cc_ids, :new_bugs, :closed_bugs, :new_tb, :closed_tb, :new_cc, :closed_cc
5
+
6
+ def initialize(snapshot)
7
+ @id = snapshot
8
+ @bug_ids = []
9
+ @tb_ids = []
10
+ @cc_ids = []
11
+ @new_bugs = 0
12
+ @closed_bugs = 0
13
+ @new_tb = 0
14
+ @closed_tb = 0
15
+ @new_cc = 0
16
+ @closed_cc = 0
17
+ end
18
+
19
+ def total_bugs
20
+ @bug_ids.length
21
+ end
22
+
23
+ def total_tb
24
+ @tb_ids.length
25
+ end
26
+
27
+ def total_cc
28
+ @cc_ids.length
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,51 @@
1
+ require 'shiftzilla/helpers'
2
+
3
+ module Shiftzilla
4
+ class Source
5
+ attr_reader :id, :table
6
+ def initialize(qid,qinfo)
7
+ @id = qid.to_sym
8
+ @search = qinfo['search']
9
+ @sharer = qinfo['sharer']
10
+ @table = qinfo['table']
11
+ @external_sub = qinfo['external_sub']
12
+ @fields = qinfo['fields'].map{ |f| f.to_sym }
13
+ @external_bugs_idx = @fields.index(:external_bugs)
14
+ end
15
+
16
+ def has_records_for_today?
17
+ count = 0
18
+ dbh.execute("SELECT count(*) FROM #{@table} WHERE Snapshot = date('now')") do |row|
19
+ count = row[0].to_i
20
+ end
21
+ return count > 0
22
+ end
23
+
24
+ def load_records
25
+ output_format = @fields.map{ |fld| "%{#{fld.to_s}}" }.join("\x1F")
26
+ table_fields = @fields.map{ |fld| "\"#{field_map[fld]}\"" }.join(',')
27
+ insert_frame = @fields.map{ |fld| '?' }.join(', ')
28
+ bz_command = "bugzilla query --savedsearch #{@search} --savedsearch-sharer-id=#{@sharer} --outputformat='#{output_format}'"
29
+ bz_csv = `#{bz_command}`
30
+ row_count = 0
31
+ bz_csv.split("\n").each do |row|
32
+ values = row.split("\x1F")
33
+ if not @external_bugs_idx.nil?
34
+ if not @external_sub.nil? and values[@external_bugs_idx].include?(@external_sub)
35
+ values[@external_bugs_idx] = 1
36
+ else
37
+ values[@external_bugs_idx] = 0
38
+ end
39
+ end
40
+ dbh.execute("INSERT INTO #{@table} (#{table_fields}) VALUES (#{insert_frame})", values)
41
+ row_count += 1
42
+ end
43
+ dbh.execute("UPDATE #{@table} SET Snapshot = date('now') WHERE Snapshot ISNULL")
44
+ return row_count
45
+ end
46
+
47
+ def purge_records
48
+ dbh.execute("DELETE FROM #{@table} WHERE Snapshot == date('now') OR Snapshot ISNULL")
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,17 @@
1
+ module Shiftzilla
2
+ class Team
3
+ attr_reader :name, :lead, :group, :components
4
+
5
+ def initialize(tinfo,group_map,ad_hoc=false)
6
+ @name = tinfo['name']
7
+ @lead = ad_hoc ? nil : tinfo['lead']
8
+ @group = ad_hoc ? nil : group_map[tinfo['group']]
9
+ @components = tinfo.has_key?('components') ? tinfo['components'] : []
10
+ @ad_hoc = ad_hoc
11
+ end
12
+
13
+ def ad_hoc?
14
+ @ad_hoc
15
+ end
16
+ end
17
+ end