oss-stats 0.0.1

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.
data/bin/meeting_stats ADDED
@@ -0,0 +1,450 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'optparse'
4
+ require 'date'
5
+ require 'sqlite3'
6
+ require 'fileutils'
7
+ require 'gruff'
8
+
9
+ require_relative '../lib/oss_stats/log'
10
+ require_relative '../lib/oss_stats/config/meeting_stats'
11
+
12
+ # Initialize database
13
+ def initialize_db(db_file)
14
+ db = SQLite3::Database.new(db_file)
15
+ db.execute <<-SQL
16
+ CREATE TABLE IF NOT EXISTS meeting_stats (
17
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
18
+ meeting_date TEXT,
19
+ team TEXT,
20
+ present TEXT,
21
+ current_work TEXT,
22
+ build_status TEXT,
23
+ fix_points TEXT,
24
+ extra TEXT,
25
+ UNIQUE(meeting_date, team)
26
+ );
27
+ SQL
28
+ db.close
29
+ end
30
+
31
+ # Get last Thursday from a given date (or today)
32
+ def get_last_thursday(target_date = Date.today)
33
+ target_date -= 1 while target_date.wday != 4 # 4 = Thursday
34
+ target_date
35
+ end
36
+
37
+ # Prompt user for a Yes/No response
38
+ def prompt_yes_no(question)
39
+ loop do
40
+ print "#{question} (y/N): "
41
+ response = gets.strip.downcase
42
+ return response == 'y' if ['y', 'n', ''].include?(response)
43
+
44
+ log.info("Please enter 'y' or 'n'.")
45
+ end
46
+ end
47
+
48
+ def prompt_team_or_q(teams)
49
+ # it's length, not length-1 because we add one for <other>
50
+ max_num = teams.length
51
+ loop do
52
+ log.info("Choose a team that was present:\n")
53
+ (teams + ['<Other>']).each_with_index do |team, idx|
54
+ log.info("\t[#{idx}] #{team}")
55
+ end
56
+ log.info("\t[q] <quit>")
57
+ response = gets.strip.downcase
58
+ return false if response == 'q'
59
+ begin
60
+ i = Integer(response)
61
+ if i < max_num
62
+ return teams[i]
63
+ end
64
+
65
+ if i == max_num
66
+ print 'Team name: '
67
+ response = gets.strip
68
+ return response
69
+ end
70
+
71
+ log.error("Invalid response: #{response}")
72
+ rescue ArgumentError
73
+ log.error("Invalid response: #{response}")
74
+ end
75
+ end
76
+ end
77
+
78
+ # Collect team data from user
79
+ def collect_team_data(meeting_date)
80
+ teams = OssStats::Config::MeetingStats.teams
81
+ team_data = {}
82
+
83
+ log.info("Please fill in data about the #{meeting_date} meeting\n")
84
+ loop do
85
+ team = prompt_team_or_q(teams)
86
+ unless team
87
+ missing_teams = teams - team_data.keys
88
+ log.info(
89
+ 'The following teams will be recorded as not present: ' +
90
+ missing_teams.join(', '),
91
+ )
92
+ if prompt_yes_no('Is that correct?')
93
+ missing_teams.each do |mt|
94
+ team_data[mt] = {
95
+ 'present' => false,
96
+ 'current_work' => false,
97
+ 'build_status' => '',
98
+ 'fix_pointers' => '-',
99
+ 'extra' => '-',
100
+ }
101
+ end
102
+ break
103
+ else
104
+ next
105
+ end
106
+ end
107
+
108
+ if team_data[team]
109
+ if prompt_yes_no("WARNING: #{team} data already input - overwrite?")
110
+ log.info("OK, overwriting data for #{team} on #{meeting_date}")
111
+ else
112
+ next
113
+ end
114
+ end
115
+
116
+ log.info("\nTeam: #{team}")
117
+ team_data[team] = {}
118
+ team_data[team]['present'] = true
119
+ team_data[team]['current_work'] = prompt_yes_no(
120
+ 'Did they discuss current work?',
121
+ )
122
+ print "Enter build status (e.g. green, red, or 'main:green, 18:red'): "
123
+ build_status = gets.strip
124
+ team_data[team]['build_status'] = build_status
125
+ fix_pointers = if build_status.include?('red')
126
+ if prompt_yes_no(
127
+ 'Did they point to work to fix the build?',
128
+ )
129
+ 'Y'
130
+ else
131
+ 'N'
132
+ end
133
+ else
134
+ '-'
135
+ end
136
+ team_data[team]['fix_pointers'] = fix_pointers
137
+ extra = []
138
+ merged_prs = prompt_yes_no('Did they list merged PRs that week?')
139
+ extra << 'listed merged PRs' if merged_prs
140
+ print 'Any extra notes? (leave empty if none): '
141
+ extra_notes = gets.strip
142
+ extra << extra_notes unless extra_notes.empty?
143
+ team_data[team]['extra'] = if extra.empty?
144
+ '-'
145
+ else
146
+ extra.join(', ')
147
+ end
148
+ end
149
+ team_data.map do |team, info|
150
+ [
151
+ team,
152
+ info['present'] ? 'Y' : 'N',
153
+ info['current_work'] ? 'Y' : 'N',
154
+ info['build_status'],
155
+ info['fix_points'],
156
+ info['extra'],
157
+ ]
158
+ end
159
+ end
160
+
161
+ # Insert meeting data into the database
162
+ def record_meeting_data(meeting_date, team_data, config)
163
+ if config.dryrun
164
+ log.info('DRYRUN: Would record the following rows:')
165
+ team_data.each do |row|
166
+ log.info(row.join(', '))
167
+ end
168
+ return
169
+ end
170
+
171
+ db = SQLite3::Database.new(config.db_file)
172
+ team_data.each do |row|
173
+ db.execute(
174
+ 'INSERT INTO meeting_stats (meeting_date, team, present, current_work,' +
175
+ 'build_status, fix_points, extra) VALUES (?, ?, ?, ?, ?, ?, ?)' +
176
+ ' ON CONFLICT(meeting_date, team) DO UPDATE' +
177
+ ' SET present=excluded.present, current_work=excluded.current_work,' +
178
+ ' build_status=excluded.build_status, fix_points=excluded.fix_points,' +
179
+ ' extra=excluded.extra',
180
+ [meeting_date.to_s] + row,
181
+ )
182
+ end
183
+ db.close
184
+ log.info("Data recorded for #{meeting_date}.")
185
+ end
186
+
187
+ # Format Yes/No to display emojis
188
+ #
189
+ # if `force`, that means nil is the same as no.
190
+ def format_yes_no(value, force = false)
191
+ return ':x:' unless value
192
+
193
+ case value.strip.upcase
194
+ when 'N'
195
+ force ? ':x:' : ':red_circle:'
196
+ when 'Y'
197
+ ':white_check_mark:'
198
+ else
199
+ value
200
+ end
201
+ end
202
+
203
+ # Format build status to display emojis correctly
204
+ def format_build_status(status)
205
+ return ':x:' if status.nil? || status.strip.empty?
206
+
207
+ if %w{red green}.include?(status)
208
+ status = "main:#{status}"
209
+ end
210
+ status.gsub('red', ' :red_circle:').gsub('green', ' :white_check_mark:')
211
+ end
212
+
213
+ # Generate Markdown table
214
+ def generate_md_page(db_file)
215
+ db = SQLite3::Database.new(db_file)
216
+ meeting_dates = db.execute(
217
+ 'SELECT DISTINCT meeting_date FROM meeting_stats ORDER BY meeting_date' +
218
+ ' DESC',
219
+ ).flatten
220
+ md = [OssStats::Config::MeetingStats.header]
221
+
222
+ meeting_dates.each do |meeting_date|
223
+ team_data = db.execute(
224
+ 'SELECT team, present, current_work, build_status, fix_points, extra' +
225
+ ' FROM meeting_stats WHERE meeting_date = ?',
226
+ [meeting_date],
227
+ )
228
+ md << "## #{meeting_date}"
229
+ md << ''
230
+ md << '| Team | Present | Current work | Build Status |' +
231
+ ' If builds broken, points to work to fix it | Extra |'
232
+ md << '| --- | ---- | --- | --- | --- | --- |'
233
+ team_data.each do |row|
234
+ row = row.dup # This makes the row mutable
235
+ row[1] = format_yes_no(row[1], true) # Present
236
+ row[2] = format_yes_no(row[2], true) # Current work
237
+ row[3] = format_build_status(row[3]) # Build Status
238
+ row[4] = if row[3].include?('❌')
239
+ format_yes_no(row[4]) # Fix points
240
+ else
241
+ '➖'
242
+ end
243
+ md << '| ' + row.join(' | ') + ' |'
244
+ end
245
+ md << ''
246
+ end
247
+ db.close
248
+ md.join("\n")
249
+ end
250
+
251
+ def summary(db_file)
252
+ db = SQLite3::Database.new(db_file)
253
+ data = db.execute(
254
+ 'SELECT meeting_date, team, present, build_status FROM meeting_stats' +
255
+ ' ORDER BY meeting_date ASC',
256
+ )
257
+ db.close
258
+
259
+ # TODO: de-dupe this with generate_plots
260
+ dates = data.map { |row| row[0] }.uniq.reverse
261
+ dates[0..2].each do |date|
262
+ total_teams = data.count { |row| row[0] == date }
263
+ present_teams = data.count { |row| row[0] == date && row[2] == 'Y' }
264
+ present_pct = ((present_teams / total_teams) * 100).round(2)
265
+ reporting_builds = data.count do |row|
266
+ row[0] == date && row[3] != 'N' && !row[3].strip.empty?
267
+ end
268
+ reporting_builds_pct =
269
+ ((reporting_builds.to_f / total_teams) * 100).round(2)
270
+
271
+ puts "* #{date}:"
272
+ puts " * Teams reported: #{present_teams} out of #{total_teams} (" +
273
+ "#{present_pct}%)"
274
+ puts " * Teams reporting build status: #{reporting_builds} out of " +
275
+ "#{total_teams} (#{reporting_builds_pct}%)\n"
276
+ end
277
+ end
278
+
279
+ def generate_plots(db_file, img_dir)
280
+ db = SQLite3::Database.new(db_file)
281
+ data = db.execute(
282
+ 'SELECT meeting_date, team, present, build_status FROM meeting_stats' +
283
+ ' ORDER BY meeting_date ASC',
284
+ )
285
+ db.close
286
+
287
+ dates = data.map { |row| row[0] }.uniq
288
+ attendance_percentages = []
289
+ build_status_percentages = []
290
+
291
+ dates.each do |date|
292
+ total_teams = data.count { |row| row[0] == date }
293
+ present_teams = data.count { |row| row[0] == date && row[2] == 'Y' }
294
+ reporting_builds = data.count do |row|
295
+ row[0] == date && row[3] != 'N' && !row[3].strip.empty?
296
+ end
297
+
298
+ attendance_percentages <<
299
+ (total_teams == 0 ? 0 : (present_teams.to_f / total_teams) * 100)
300
+ build_status_percentages <<
301
+ (total_teams == 0 ? 0 : (reporting_builds.to_f / total_teams) * 100)
302
+ end
303
+
304
+ sizes = {
305
+ 'full' => [800, 500],
306
+ 'small' => [400, 250],
307
+ }
308
+
309
+ sizes.each do |name, size|
310
+ g = Gruff::Line.new(size[0], size[1])
311
+ g.maximum_value = 100
312
+ g.minimum_value = 0
313
+ g.title = 'Percentage of Teams Present Over Time'
314
+ g.data('% Teams Present', attendance_percentages)
315
+ g.labels = dates.each_with_index.to_h
316
+ g.write(::File.join(img_dir, "attendance-#{name}.png"))
317
+
318
+ g2 = Gruff::Line.new(size[0], size[1])
319
+ g2.maximum_value = 100
320
+ g2.minimum_value = 0
321
+ g2.title = 'Percentage of Teams Reporting Build Status Over Time'
322
+ g2.data('% Reporting Build Status', build_status_percentages)
323
+ g2.labels = dates.each_with_index.to_h
324
+ g2.write(::File.join(img_dir, "build_status-#{name}.png"))
325
+ end
326
+ end
327
+
328
+ # Parse command-line arguments
329
+ options = {}
330
+ OptionParser.new do |opts|
331
+ opts.banner = 'Usage: meeting_stats.rb [options]'
332
+
333
+ opts.on(
334
+ '-c FILE',
335
+ '--config FILE',
336
+ 'Config file to load. [default: will look for `meeting_stats_config.rb` ' +
337
+ 'in `./`, `~/.config/oss_stats`, and `/etc`]',
338
+ ) do |c|
339
+ options[:config] = c
340
+ end
341
+
342
+ opts.on(
343
+ '--date DATE',
344
+ 'Date of the meeting in YYYY-MM-DD format',
345
+ ) do |v|
346
+ options[:date] =
347
+ begin
348
+ Date.parse(v)
349
+ rescue
350
+ nil
351
+ end
352
+ end
353
+
354
+ opts.on(
355
+ '-f FILE',
356
+ '--db-file FILE',
357
+ 'SQLLite file. Will be created if it does not exist. ' +
358
+ '[default: ./data/meeting_data.sqlite3]',
359
+ ) do |f|
360
+ options[:db_file] = f
361
+ end
362
+
363
+ opts.on(
364
+ '-i DIR',
365
+ '--image-dir DIR',
366
+ 'Directory to drop plot images in. [default: ./images]',
367
+ ) do |dir|
368
+ options[:image_dir] = dir
369
+ end
370
+
371
+ opts.on(
372
+ '-l LEVEL',
373
+ '--log-level LEVEL',
374
+ 'Set logging level to LEVEL. [default: info]',
375
+ ) do |level|
376
+ options[:log_level] = level.to_sym
377
+ end
378
+
379
+ opts.on(
380
+ '-m MODE',
381
+ '--mode MODE',
382
+ %w{record generate generate_plot summary},
383
+ 'Mode to operate in. record: Input new meeting info, generate: generate ' +
384
+ 'both plot and markdown files, generate_plot: generate new plots, ' +
385
+ 'summary: generate summary of last 3 meetings [default: record]',
386
+ ) do |v|
387
+ options[:mode] = v
388
+ end
389
+
390
+ opts.on('-n', '--dryrun', 'Do not actually make changes') do |_v|
391
+ options[:dryrun] = true
392
+ end
393
+
394
+ opts.on(
395
+ '-o FILE',
396
+ '--output FILE',
397
+ 'Write output to FILE [default: ./meeting_stats.md]',
398
+ ) do |f|
399
+ options[:output] = f
400
+ end
401
+ end.parse!
402
+ log.level = options[:log_level] if options[:log_level]
403
+ config = OssStats::Config::MeetingStats
404
+
405
+ if options[:config]
406
+ expanded_config = File.expand_path(options[:config])
407
+ else
408
+ f = config.config_file
409
+ expanded_config = File.expand_path(f) if f
410
+ end
411
+
412
+ if expanded_config && File.exist?(expanded_config)
413
+ log.info("Loading config from #{expanded_config}")
414
+ config.from_file(expanded_config)
415
+ end
416
+ config.merge!(options)
417
+ log.level = config.log_level
418
+
419
+ log.debug("Full config: #{config.to_hash}")
420
+
421
+ initialize_db(config.db_file)
422
+ meeting_date = config.date || get_last_thursday
423
+
424
+ case config.mode
425
+ when 'record'
426
+ team_data = collect_team_data(meeting_date)
427
+ record_meeting_data(meeting_date, team_data, config)
428
+ when 'generate'
429
+ if config.dryrun
430
+ log.info('DRYRUN: Would update plots')
431
+ log.info("DRYRUN: Would update #{config.output} with:")
432
+ log.info(generate_md_page(config.db_file))
433
+ else
434
+ log.info('Updating plots...')
435
+ generate_plots(config.db_file, config.image_dir)
436
+ log.info("Generating #{config.output}")
437
+ File.write(config.output, generate_md_page(config.db_file))
438
+ end
439
+ when 'generate_plot'
440
+ if config.dryrun
441
+ log.info('DRYRUN: Would update plots')
442
+ else
443
+ generate_plots(config.db_file, config.image_dir)
444
+ log.info('Plots generated: attendance.png and build_status.png')
445
+ end
446
+ when 'summary'
447
+ summary(config.db_file)
448
+ else
449
+ log.info('Invalid mode. Use --mode record, markdown, or plot.')
450
+ end