#!/usr/bin/env ruby # Copyright (c) 2022 Yegor Bugayenko # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the 'Software'), to deal # in the Software without restriction, including without limitation the rights # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell # copies of the Software, and to permit persons to whom the Software is # furnished to do so, subject to the following conditions: # # The above copyright notice and this permission notice shall be included in all # copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFINGEMENT. IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. STDOUT.sync = true require 'slop' require 'loog' require 'octokit' require 'nokogiri' require 'backtrace' require 'fileutils' require 'obk' require_relative '../lib/cobench/version' loog = Loog::REGULAR def config(path) f = File.expand_path(path) args = [] args += File.readlines(f).map(&:strip).reject { |a| a.empty? } if File.exist?(f) args end args = config('~/.cobench') + config('.cobench') + ARGV opts = Slop.parse(args, strict: true, help: true) do |o| o.banner = "Usage (#{Cobench::VERSION}): cobench [options]" o.bool '-h', '--help', 'Show these instructions' o.bool '--version', 'Show current version' o.bool '--verbose', 'Print as much log messages as possible' o.bool '--dry', 'Make no real round trips to GitHub' o.bool '--reuse', 'Don\'t fetch from GitHub, reuse the existing XML file' o.integer '--days', 'How many days to measure', default: 7 o.string '--to', 'Directory where to save all files to', default: './cobench' o.string '--token', 'GitHub authentication token' o.array '--coder', 'GitHub nickname of a coder to track' o.array '--metrics', 'Names of metrics to use (all by default)' o.array '--include', 'Mask of GitHub repo to include, e.g. yegor256/*' o.array '--exclude', 'Mask of GitHub repo to exclude' end if opts.help? || opts[:coder].empty? puts opts exit end if opts.verbose? loog = Loog::VERBOSE end if opts.version? loog.info(Cobench::VERSION) exit end Encoding.default_external = Encoding::UTF_8 Encoding.default_internal = Encoding::UTF_8 def div(a, b) return 0 if b == 0 a / b end def actual(ms) return 0 if ms.nil? return ms[:actual] if ms.key?(:actual) ms[:total] end def build_xml(opts, loog) if opts.token? api = Octokit::Client.new(:access_token => opts[:token]) else api = Octokit::Client.new loog.warn("Connecting to GitHub without a token, this may lead to errors, use --token") end api.auto_paginate = true api = Obk.new(api, pause: 2000) loog.info("Reading GitHub data for the last #{opts[:days]} days") titles = {} data = {} orgs = {} opts[:coder].each do |u| user = u.downcase loog.info("Scanning #{user}...") data[user] = {} Dir[File.join(__dir__, '../lib/cobench/metrics/*.rb')].each do |f| name = File::basename(f).split('.')[0] if !opts[:metrics].empty? && !opts[:metrics].include?(name) loog.info("Ignoring #{user}/#{name} due to --metrics") next end type = "Cobench::#{name.capitalize}" loog.info("Reading #{user}/#{name}...") require_relative f m = type.split('::').reduce(Module, :const_get).new(api, user, opts) if opts.dry? measures = [ { title: 'Issues', total: Random.new.rand(100), href: 'https://github.com/' }, { title: 'Pulls', total: Random.new.rand(100) }, { title: 'Commits', total: Random.new.rand(100) }, { title: 'HoC', total: Random.new.rand(100) }, { title: 'HoC', total: Random.new.rand(100) }, { meta: true, title: 'Orgs', list: ['objectionary', 'artipie'] }, ] else measures = m.take(loog) end measures.reject {|ms| ms.key?(:meta)}.each do |ms| before = 0 before += data[user][ms[:title]][:total] if data[user][ms[:title]] != nil data[user][ms[:title]] = { total: ms[:total] + before, href: ms[:href] } titles[ms[:title]] = ms[:title] loog.info("The value of #{user}/#{ms[:title]} is #{ms[:total]}") end measures.select {|ms| ms.key?(:meta)}.each do |ms| if ms[:title] == 'Orgs' orgs[user] = [] unless orgs.key?(user) end orgs[user] += ms[:list] end end end caps = { 'HoC' => lambda { |ms| ms['Pulls'][:total] * 1024 }, } data.each do |u, ms| ms.map do |t, h| next unless caps.key?(t) cap = caps[t].call(ms) if h[:total] > cap data[u][t][:actual] = h[:total] data[u][t][:total] = cap end end data[u] = ms end weights = { 'HoC' => 1, 'Pulls' => 250, 'Issues' => 50, 'Commits' => 5, 'Reviews' => 150, 'Msgs' => 5 } data.each do |u, ms| score = ms.map do |t, h| raise "Unknown title '#{t}'" unless weights.key?(t) h[:total] * weights[t] end.inject(0, :+) data[u]['Score'] = { total: score } end averages = { 'CpP': { f: lambda { |ms| div(actual(ms['Commits']), actual(ms['Pulls'])) }, title: 'Commits per Pull Request' }, 'HpP': { f: lambda { |ms| div(actual(ms['HoC']), actual(ms['Pulls'])) }, title: 'HoC per Pull Request' }, 'HpC': { f: lambda { |ms| div(actual(ms['HoC']), actual(ms['Commits'])) }, title: 'HoC per Commit' }, 'MpRP': { f: lambda { |ms| div(actual(ms['Msgs']), actual(ms['Reviews']) + actual(ms['Pulls'])) }, title: 'Messages per Review+Pulls' } } data.each do |u, ms| averages.each do |k, a| data[u][k] = { total: a[:f].call(ms) } end end builder = Nokogiri::XML::Builder.new(:encoding => 'UTF-8') do |xml| xml.cobench(time: Time.now, days: opts[:days]) do xml.titles do data.map { |_, ms| ms.keys }.flatten.uniq.each do |t| xml.title do xml.parent.set_attribute('subtitle', averages[t][:title]) if averages.key?(t) xml.text t end end end xml.weights do weights.each do |t, w| xml.w(id: t) do xml.text(w) end end end xml.totals do data.map { |_, ms| ms.keys }.flatten.uniq.each do |t| next if t == 'Score' next if averages.key?(t) xml.w(id: t) do xml.text(data.values.map { |ms| actual(ms[t]) }.inject(&:+)) end end end xml.averages do data.map { |_, ms| ms.keys }.flatten.uniq.each do |t| next if t == 'Score' xml.w(id: t) do vals = data.values.map { |ms| actual(ms[t]) }.select { |v| v > 0 } xml.text(div(vals.inject(&:+), vals.count)) end end end xml.coders do data.each do |u, ms| xml.coder(id: u) do xml.parent.set_attribute('details', api.user(u).name) unless opts[:dry] if orgs.key?(u) xml.orgs do orgs[u].uniq.each do |o| xml.org o end end end xml.metrics do ms.each do |k, v| xml.m(id: k) do xml.parent.set_attribute('actual', v[:actual]) unless v[:actual].nil? xml.parent.set_attribute('href', v[:href]) unless v[:href].nil? xml.text v[:total] end end end end end end end end xml = builder.to_xml loog.debug(xml) xml end begin home = File.absolute_path(opts[:to]) loog.debug("All files generated will be saved to #{home}") if File.exist?(home) loog.debug("Directory #{home} exists") else FileUtils.mkdir_p(home) loog.debug("Directory #{home} created") end index = File.join(home, 'index.xml') if opts[:reuse] xml = File.read(index) else xml = build_xml(opts, loog) File.write(index, xml) loog.debug("XML saved to #{index} (#{File.size(index)} bytes)") end xslt = Nokogiri::XSLT(File.read(File.join(__dir__, '../assets/index.xsl'))) html = xslt.transform(Nokogiri::XML(xml), 'version' => "'#{Cobench::VERSION}'") loog.debug(html) front = File.join(home, 'index.html') File.write(front, html.to_html(indent: 0).gsub("\n", '')) loog.debug("HTML saved to #{front} (#{File.size(front)} bytes)") rescue StandardError => e loog.error(Backtrace.new(e)) exit -1 end