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/promise_stats ADDED
@@ -0,0 +1,312 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'sqlite3'
4
+ require 'optparse'
5
+ require 'date'
6
+
7
+ require_relative '../lib/oss_stats/log'
8
+ require_relative '../lib/oss_stats/config/promise_stats'
9
+
10
+ log.level = :info
11
+
12
+ def initialize_db(path)
13
+ db = SQLite3::Database.new(path)
14
+ db.execute <<-SQL
15
+ CREATE TABLE IF NOT EXISTS promises (
16
+ id INTEGER PRIMARY KEY,
17
+ description TEXT NOT NULL,
18
+ promised_on DATE NOT NULL,
19
+ resolved_on DATE,
20
+ reference TEXT,
21
+ status TEXT DEFAULT 'pending'
22
+ );
23
+ SQL
24
+ db.close
25
+ end
26
+
27
+ def parse_date(str)
28
+ Date.parse(str)
29
+ rescue ArgumentError
30
+ puts "Invalid date: #{str}"
31
+ exit 1
32
+ end
33
+
34
+ def add_promise(config, desc, date, ref)
35
+ db = SQLite3::Database.new(config.db_file)
36
+ db.execute(
37
+ "INSERT INTO promises (description, promised_on, reference)
38
+ VALUES (?, ?, ?)",
39
+ [desc, date.to_s, ref],
40
+ )
41
+ db.close
42
+ puts 'Promise added.'
43
+ end
44
+
45
+ def resolve_promise(config, date)
46
+ update_promise_status(config, date, 'resolved')
47
+ end
48
+
49
+ def abandon_promise(config, date)
50
+ update_promise_status(config, date, 'abandoned')
51
+ end
52
+
53
+ def update_promise_status(config, date, new_status)
54
+ db = SQLite3::Database.new(config.db_file)
55
+ rows = db.execute(
56
+ "SELECT id, description FROM promises WHERE status = 'pending'",
57
+ )
58
+ if rows.empty?
59
+ puts 'No pending promises.'
60
+ db.close
61
+ return
62
+ end
63
+
64
+ puts 'Pending promises:'
65
+ rows.each_with_index do |(id, desc), _i|
66
+ puts "#{id}. #{desc}"
67
+ end
68
+
69
+ print 'Enter ID to update: '
70
+ chosen_id = gets.strip.to_i
71
+ if rows.any? { |r| r[0] == chosen_id }
72
+ if new_status == 'resolved'
73
+ db.execute(
74
+ "UPDATE promises SET resolved_on = ?, status = 'resolved' " +
75
+ 'WHERE id = ?',
76
+ [date.to_s, chosen_id],
77
+ )
78
+ elsif new_status == 'abandoned'
79
+ db.execute(
80
+ "UPDATE promises SET resolved_on = ?, status = 'abandoned' " +
81
+ 'WHERE id = ?',
82
+ [date.to_s, chosen_id],
83
+ )
84
+ end
85
+ puts "Promise marked as #{new_status}."
86
+ else
87
+ puts 'Invalid ID.'
88
+ end
89
+ db.close
90
+ end
91
+
92
+ def edit_promise(config)
93
+ db = SQLite3::Database.new(config.db_file)
94
+ rows = db.execute(
95
+ 'SELECT id, description, promised_on, reference FROM promises',
96
+ )
97
+ if rows.empty?
98
+ puts 'No promises available.'
99
+ db.close
100
+ return
101
+ end
102
+
103
+ puts 'All promises:'
104
+ rows.each_with_index do |(id, desc, date, ref), i|
105
+ puts "#{i + 1}. #{desc} (ID: #{id}, Date: #{date}, Ref: #{ref})"
106
+ end
107
+
108
+ print 'Enter ID to edit: '
109
+ chosen_id = gets.strip.to_i
110
+ entry = rows.find { |r| r[0] == chosen_id }
111
+
112
+ unless entry
113
+ puts 'Invalid ID.'
114
+ db.close
115
+ return
116
+ end
117
+
118
+ print "New description [#{entry[1]}]: "
119
+ new_desc = gets.strip
120
+ new_desc = entry[1] if new_desc.empty?
121
+
122
+ print "New date (YYYY-MM-DD) [#{entry[2]}]: "
123
+ new_date = gets.strip
124
+ new_date = entry[2] if new_date.empty?
125
+ new_date = parse_date(new_date)
126
+
127
+ print "New reference [#{entry[3]}]: "
128
+ new_ref = gets.strip
129
+ new_ref = entry[3] if new_ref.empty?
130
+
131
+ db.execute(
132
+ "UPDATE promises
133
+ SET description = ?, promised_on = ?, reference = ?
134
+ WHERE id = ?",
135
+ [new_desc, new_date.to_s, new_ref, chosen_id],
136
+ )
137
+ puts 'Promise updated.'
138
+ db.close
139
+ end
140
+
141
+ def output(fh, msg)
142
+ if fh
143
+ fh.write("#{msg}\n")
144
+ else
145
+ log.info(msg)
146
+ end
147
+ end
148
+
149
+ def show_status(config, include_abandoned: false)
150
+ db = SQLite3::Database.new(config.db_file)
151
+ query = "SELECT description, promised_on, reference, status
152
+ FROM promises WHERE status = 'pending'"
153
+ if include_abandoned
154
+ query += " OR status = 'abandoned'"
155
+ end
156
+ rows = db.execute(query)
157
+ db.close
158
+
159
+ fh = nil
160
+ if config.output
161
+ fh = open(config.output, 'w')
162
+ log.info("Generating report and writing to #{config.output}")
163
+ end
164
+
165
+ output(fh, config.header)
166
+
167
+ if rows.empty?
168
+ output(fh, 'No matching promises.')
169
+ return
170
+ end
171
+
172
+ today = Date.today
173
+ rows.each do |desc, promised_on, ref, status|
174
+ days = (today - Date.parse(promised_on)).to_i
175
+ label = "#{desc} (#{days} days ago)"
176
+ label += " [ref: #{ref}]" unless ref.empty?
177
+ label += " [#{status}]" if status == 'abandoned'
178
+ output(fh, "* #{label}")
179
+ end
180
+ end
181
+
182
+ def prompt_date(txt = 'Date')
183
+ parse_date(prompt(txt, Date.today.to_s))
184
+ end
185
+
186
+ def prompt(txt, default = nil)
187
+ p = txt
188
+ if default
189
+ p << " [#{default}]"
190
+ end
191
+ print "#{p}: "
192
+ resp = gets.strip
193
+ if resp.empty? && default
194
+ return default
195
+ end
196
+ resp
197
+ end
198
+
199
+ def main
200
+ if ARGV.empty? || %w{--help -h}.include?(ARGV[0])
201
+ puts <<~HELP
202
+ Usage: #{$PROGRAM_NAME} [subcommand] [options]
203
+
204
+ Subcommands:
205
+ add-promise Add a new promise
206
+ resolve-promise Resolve a pending promise
207
+ abandon-promise Mark a promise as abandoned
208
+ edit-promise Edit an existing promise
209
+ status Show unresolved promises
210
+
211
+ Options:
212
+ --date=YYYY-MM-DD Date of action
213
+ --promise="text" Promise description
214
+ --reference="text" Optional reference
215
+ --db-file=FILE SQLite3 DB file
216
+ --include-abandoned Show abandoned in status
217
+
218
+ Example:
219
+ #{$PROGRAM_NAME} add-promise --promise="Call mom" --date=2025-05-08
220
+ HELP
221
+ exit
222
+ end
223
+
224
+ options = {}
225
+ OptionParser.new do |opts|
226
+ opts.on('--date=DATE', 'Date (YYYY-MM-DD)') do |d|
227
+ options[:date] = parse_date(d)
228
+ end
229
+
230
+ opts.on('--promise=TEXT', 'Promise text') do |p|
231
+ options[:promise] = p
232
+ end
233
+
234
+ opts.on('--reference=TEXT', 'Optional reference') do |r|
235
+ options[:reference] = r
236
+ end
237
+
238
+ opts.on(
239
+ '-l LEVEL',
240
+ '--log-level LEVEL',
241
+ 'Set logging level to LEVEL. [default: info]',
242
+ ) do |level|
243
+ options[:log_level] = level.to_sym
244
+ end
245
+
246
+ opts.on('--db-file=FILE', 'Path to DB file') do |f|
247
+ options[:db_file] = f
248
+ end
249
+
250
+ opts.on('--include-abandoned', 'Include abandoned in status') do
251
+ options[:include_abandoned] = true
252
+ end
253
+
254
+ opts.on(
255
+ '-o FILE',
256
+ '--output FILE',
257
+ 'Write the output to FILE',
258
+ ) { |v| options[:output] = v }
259
+
260
+ opts.on('-m MODE', '--mode MODE',
261
+ %w{add resolve abandon edit status},
262
+ 'Mode to operate in') do |m|
263
+ options[:mode] = m
264
+ end
265
+
266
+ opts.on('-h', '--help', 'Show help') do
267
+ puts opts
268
+ exit
269
+ end
270
+ end.parse!
271
+ log.level = options[:log_level] if options[:log_level]
272
+ config = OssStats::Config::Promises
273
+
274
+ if options[:config]
275
+ expanded_config = File.expand_path(options[:config])
276
+ else
277
+ f = config.config_file
278
+ expanded_config = File.expand_path(f) if f
279
+ end
280
+
281
+ if expanded_config && File.exist?(expanded_config)
282
+ log.debug("Loading config from #{expanded_config}")
283
+ config.from_file(expanded_config)
284
+ end
285
+ config.merge!(options)
286
+ log.level = config.log_level
287
+
288
+ initialize_db(config.db_file)
289
+
290
+ case config.mode
291
+ when 'add'
292
+ desc = options[:promise] || prompt('Promise')
293
+ date = options[:date] || promt_date
294
+ ref = options[:reference] || prompt('Reference (optional)')
295
+ add_promise(config, desc, date, ref)
296
+ when 'resolve'
297
+ date = options[:date] || prompt_date('Resolution date')
298
+ resolve_promise(config, date)
299
+ when 'abandon'
300
+ date = options[:date] || prompt_date('Resolution date')
301
+ abandon_promise(config, date)
302
+ when 'edit'
303
+ edit_promise(config)
304
+ when 'status'
305
+ show_status(config, include_abandoned: options[:include_abandoned])
306
+ else
307
+ puts "Unknown mode: #{config.mode}"
308
+ exit 1
309
+ end
310
+ end
311
+
312
+ main if __FILE__ == $PROGRAM_NAME
data/bin/repo_stats ADDED
@@ -0,0 +1,113 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require_relative '../lib/oss_stats/repo_stats'
4
+
5
+ # quick hack to rename this without .rb and jam methods in lib
6
+ extend OssStats::RepoStats
7
+
8
+ def main
9
+ parse_options
10
+ config = OssStats::Config::RepoStats
11
+ mode = config.mode
12
+ mode = %w{ci pr issue} if mode.include?('all')
13
+
14
+ organizations_to_process = determine_orgs_to_process
15
+
16
+ # Prepare list of repositories to process based on configuration
17
+ repos_to_process = []
18
+ if organizations_to_process.empty?
19
+ log.warn('No organizations or repositories configured to process. Exiting.')
20
+ exit 0
21
+ end
22
+
23
+ # get tokens early so we fail if we're missing them
24
+ gh_token = get_github_token!(config)
25
+ gh_client = Octokit::Client.new(
26
+ access_token: gh_token,
27
+ api_endpoint: config.github_api_endpoint,
28
+ )
29
+
30
+ if mode.include?('ci') && config.buildkite_org
31
+ bk_token = get_buildkite_token!(config)
32
+ bk_client = OssStats::BuildkiteClient.new(bk_token)
33
+ bk_pipelines_by_repo = bk_client.pipelines_by_repo(config.buildkite_org)
34
+ end
35
+
36
+ organizations_to_process.each do |org_name, org_level_config|
37
+ log.debug("Processing configuration for organization: #{org_name}")
38
+ repos = org_level_config['repositories'] || {}
39
+ repos.each do |repo_name, repo_level_config|
40
+ log.debug("Processing configuration for repository: #{repo_name}")
41
+ repos_to_process << get_effective_repo_settings(
42
+ org_name, repo_name, org_level_config, repo_level_config
43
+ )
44
+ end
45
+ end
46
+
47
+ if repos_to_process.empty?
48
+ log.info(
49
+ 'No repositories found to process after evaluating configurations.',
50
+ )
51
+ exit 0
52
+ end
53
+
54
+ # Process each configured repository
55
+ all_repo_stats = []
56
+ repos_to_process.each do |settings|
57
+ repo_full_name = "#{settings[:org]}/#{settings[:repo]}"
58
+ repo_url = "https://github.com/#{repo_full_name}"
59
+
60
+ repo_data = {
61
+ name: repo_full_name,
62
+ url: repo_url,
63
+ settings:,
64
+ pr_issue_stats: nil,
65
+ ci_failures: nil,
66
+ }
67
+
68
+ # Fetch PR and Issue stats if PR or Issue mode is active
69
+ if %w{pr issue}.any? { |m| mode.include?(m) }
70
+ repo_data[:pr_issue_stats] = get_pr_and_issue_stats(gh_client, settings)
71
+ end
72
+
73
+ # Fetch CI stats if CI mode is active
74
+ if mode.include?('ci')
75
+ repo_data[:ci_failures] = get_failed_tests_from_ci(
76
+ gh_client, bk_client, settings, bk_pipelines_by_repo
77
+ )
78
+ end
79
+ all_repo_stats << repo_data
80
+ end
81
+
82
+ filtered_repos = filter_repositories(all_repo_stats, config)
83
+
84
+ filtered_repos.each do |repo_data|
85
+ if OssStats::Config::RepoStats.no_links
86
+ log.info(
87
+ "\n* #{repo_data[:name]} Stats (Last #{repo_data[:settings][:days]}" +
88
+ ' days) *',
89
+ )
90
+ else
91
+ log.info(
92
+ "\n*_[#{repo_data[:name]}](#{repo_data[:url]}) Stats " +
93
+ "(Last #{repo_data[:settings][:days]} days)_*",
94
+ )
95
+ end
96
+
97
+ if repo_data[:pr_issue_stats]
98
+ %w{PR Issue}.each do |type|
99
+ next unless mode.include?(type.downcase)
100
+ print_pr_or_issue_stats(
101
+ repo_data[:pr_issue_stats], type, config.include_list
102
+ )
103
+ end
104
+ end
105
+
106
+ next unless repo_data[:ci_failures]
107
+ # Ensure CI mode was active for this data to be present and printed
108
+ next unless mode.include?('ci')
109
+ print_ci_status(repo_data[:ci_failures])
110
+ end
111
+ end
112
+
113
+ main if __FILE__ == $PROGRAM_NAME
@@ -0,0 +1,69 @@
1
+ # MeetingStats
2
+
3
+ Many open source projects have weekly meetings and it's important to know
4
+ that the relevant teams are showing up and reporting the expected data.
5
+
6
+ [meeting_stats](../bin/meeting_stats.rb) will allow you to record the results
7
+ of a meeting and then generate a report, including images with trends over
8
+ time.
9
+
10
+ You will **definitely** want a config file to make this useful for your
11
+ project, and you can see
12
+ [examples/meeting_stats_config.rb](../examples/meeting_stats_config.rb) for an
13
+ example.
14
+
15
+ It keeps all data in a SQLite DB. You can specify where this is with
16
+ `--db-file`, and the default is `./data/meeting_data.sqlite3`.
17
+
18
+ All CLI options can be specified in the config file.
19
+
20
+ There are several modes, discussed below.
21
+
22
+ ## Modes
23
+
24
+ ### Generate
25
+
26
+ This mode generates the markdown file with a table for every meeting and graphs
27
+ at the top to show statistics over time.The markdown file is written to stdout
28
+ by default, unless `--output` is specified.
29
+
30
+ This mode is activated with `--mode generate`.
31
+
32
+ This mode implicitly runs the `generate_plot` mode to regenerate the graph
33
+ images. See the section on that mode for more details.
34
+
35
+ ### Generate Plot
36
+
37
+ This mode regenerate the plot graphs that show data over time (which are linked
38
+ in the Markdown generated by `generate` mode). These graphs are written to
39
+ `./images` by default, but you can change this with `--image-dir`.
40
+
41
+ This mode is activated with `--mode generate_plot`.
42
+
43
+ ### Summary
44
+
45
+ This mode print short summary of the last three meetings in Slack-friendly
46
+ Markdown format. Sample output is:
47
+
48
+ ```markdown
49
+ * 2025-06-05:
50
+ * Teams reported: 7 out of 7 (100%)
51
+ * Teams reporting build status: 6 out of 7 (85.71%)
52
+ * 2025-05-29:
53
+ * Teams reported: 7 out of 7 (100%)
54
+ * Teams reporting build status: 6 out of 7 (85.71%)
55
+ * 2025-05-22:
56
+ * Teams reported: 7 out of 7 (100%)
57
+ * Teams reporting build status: 6 out of 7 (85.71%)
58
+ ```
59
+
60
+ ### Record
61
+
62
+ This mode is how you feed data into the database. It will prompt you to select
63
+ a team, and then ask information such as if they were present, and if they
64
+ reported their build status. Then it'll ask you to pick another team, and will
65
+ repeat until you hit `q`, at which point it'll prompt to commit the new meeting
66
+ information to the database.
67
+
68
+ It defaults to the most recent Thursday for historical reasons, use `--date` to
69
+ specify the date of the meeting.
@@ -0,0 +1,51 @@
1
+ # Pipeline visibility stats
2
+
3
+ [pipeline_visibility_stats](../bin/pipeline_visibility_stats.rb) is a tool
4
+ which walks Buildkite pipelines associated with your public GitHub repositories
5
+ to ensure they are visible to contributors. It has a variety of options to
6
+ exclude pipelines intended to be private (for example, pipelines that may have
7
+ secrets to do pushes).
8
+
9
+ There are two providers: buildkite and expeditor. Expeditor is deprecated
10
+ and will go away.
11
+
12
+ ## Buildkite Provider
13
+
14
+ This attempts to find improperly configured pipelines in two ways:
15
+
16
+ * Given the buildkite repo, gets a list of all pipelines and builds a
17
+ map of GitHub Repos to pipelines. Then, it walks all GH repos
18
+ repos (either in the GH Org, or specified in the config), and checks
19
+ to see if there are buildkite repos associated with it, and if there are,
20
+ checks their visibility settings
21
+ * Walks the most recent 10 PRs, and checks for any status checks that are
22
+ on buildkite, and checks if it can see them, and if so, checks their
23
+ visibility (it reporst them as private if it cannot see them)
24
+
25
+ This is likely to include pipelines expected to be public such as those
26
+ added adhoc to specific PRs to do builds. You can use --skip to add skip
27
+ patterns (partial-match text) to avoid counting those.
28
+
29
+ Example output looks like (this is truncated for brevity):
30
+
31
+ ```markdown
32
+ # Chef Pipeline Visibility Report 2025-06-14
33
+
34
+ * [chef/chef-cli](https://github.com/chef/chef-cli)
35
+ * chef/chef-chef-cli-main-habitat-test
36
+ * [chef/chef-foundation](https://github.com/chef/chef-foundation)
37
+ * chef/chef-chef-foundation-main-verify
38
+ * [chef/chef-powershell-shim](https://github.com/chef/chef-powershell-shim)
39
+ * chef/chef-chef-powershell-shim-pipeline-18-stable-habitat-build
40
+ * chef/chef-chef-powershell-shim-pipeline-stable-18-verify
41
+ ```
42
+
43
+ At a minimum you will need the optoins `--github-org` and `--buildkite-org`.
44
+
45
+ ## Expeditor Provider
46
+
47
+ The Expeditor Provider parses expeditor configs, which is Chef-specific and not
48
+ open source. However, much of the code is generic and this could be adapted to
49
+ other things.
50
+
51
+ If you do want to use it, you can do so with `--provider=expeditor`.
@@ -0,0 +1,56 @@
1
+ # Promises
2
+
3
+ [promises](../bin/promises.rb) allows you to add, edit, resolve, abandon, and
4
+ report on promises made. This can be useful for both promises made to the
5
+ community or promises made between teams.
6
+
7
+ You likely will probably want a config file for this; a sample
8
+ is provided in [../examples/promises_config.rb](../examples/promises_config.rb).
9
+
10
+ There are several sub-commands, discussed below.
11
+
12
+ ## Subcommands
13
+
14
+ ### add-promise
15
+
16
+ This subcommand allows you to add a new promise. By default the data will be
17
+ assumed to be today, but it may be changed with `--date`. You will be prompted
18
+ for the promise, but you can specify it with `--promise`.
19
+
20
+ Promises have 3 pieces of data associated with them:
21
+
22
+ * `promise` - what was actually promised
23
+ * `date` - the date on which it was promised
24
+ * `reference` - additional reference about this promise. This could be
25
+ a link to the message, post, notes, etc. in which the promise was made,
26
+ for example. It is arbitrary text and may be whatever you wish.
27
+
28
+ ### resolve-promise
29
+
30
+ Mark a promise as resolved. It will no longer be reported on by default, and
31
+ the date it was resolved will be recorded in the database.
32
+
33
+ ### abandon-promise
34
+
35
+ This marks a promise as abandoned and is useful in the case of a promise that
36
+ is either no longer relevant or is not expected to be resolved.
37
+
38
+ ### edit-promise
39
+
40
+ If you want to alter information about a promise in the database, this will
41
+ re-prompt you for the information and update the database accordingly.
42
+
43
+ ### status
44
+
45
+ This will output information about the promises in a Slack-friendly Markdown
46
+ format. It will list all open promises and how long they've been open. Example
47
+ output is:
48
+
49
+ ```markdown
50
+ # Promises Report 2025-06-14
51
+
52
+ * Publish Chef 19 / 2025 plan (247 days ago)
53
+ * Fedora 41+ support (227 days ago)
54
+ ```
55
+
56
+ You can include abandoned promises with `--include-abandoned`.