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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +5 -0
- data/CONTRIBUTING.md +32 -0
- data/Gemfile +11 -0
- data/LICENSE +201 -0
- data/README.md +110 -0
- data/bin/meeting_stats +450 -0
- data/bin/pipeline_visibility_stats +636 -0
- data/bin/promise_stats +312 -0
- data/bin/repo_stats +113 -0
- data/docs/MeetingStats.md +69 -0
- data/docs/PipelineVisibilityStats.md +51 -0
- data/docs/PromiseStats.md +56 -0
- data/docs/RepoStats.md +130 -0
- data/examples/meeting_stats_config.rb +22 -0
- data/examples/promise_stats_config.rb +23 -0
- data/examples/repo_stats_config.rb +49 -0
- data/initialization_data/Gemfile +3 -0
- data/initialization_data/README.md +20 -0
- data/initialization_data/rubocop.yml +2 -0
- data/lib/oss_stats/buildkite_client.rb +252 -0
- data/lib/oss_stats/buildkite_token.rb +15 -0
- data/lib/oss_stats/config/meeting_stats.rb +36 -0
- data/lib/oss_stats/config/promise_stats.rb +22 -0
- data/lib/oss_stats/config/repo_stats.rb +47 -0
- data/lib/oss_stats/config/shared.rb +43 -0
- data/lib/oss_stats/github_client.rb +55 -0
- data/lib/oss_stats/github_token.rb +23 -0
- data/lib/oss_stats/log.rb +25 -0
- data/lib/oss_stats/repo_stats.rb +1048 -0
- data/lib/oss_stats/version.rb +3 -0
- data/oss-stats.gemspec +39 -0
- data/spec/buildkite_client_spec.rb +171 -0
- data/spec/repo_stats_spec.rb +1242 -0
- metadata +181 -0
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
|