313 lines · 9.9 KB · Text
RawDownload
class UpdatesCLI < Thor  # The GitHub user that is allowed to upload reports  UPLOAD_USER = 'devdocs-bot'   # The repository to create an issue in when uploading the results  UPLOAD_REPO = 'freeCodeCamp/devdocs'   def self.to_s    'Updates'  end   def initialize(*args)    require 'docs'    require 'progress_bar'    require 'terminal-table'    require 'date'    super  end   desc 'check [--markdown] [--github-token] [--upload] [--verbose] [doc]...', 'Check for outdated documentations'  option :markdown, :type => :boolean  option :github_token, :type => :string  option :upload, :type => :boolean  option :verbose, :type => :boolean  def check(*names)    # Convert names to a list of Scraper instances    # Versions are omitted, if v10 is outdated than v8 is aswell    docs = names.map {|name| Docs.find(name.split(/@|~/)[0], false)}.uniq     # Check all documentations for updates when no arguments are given    docs = Docs.all if docs.empty?     opts = {      logger: logger    }     if options.key?(:github_token)      opts[:github_token] = options[:github_token]    end     with_progress_bar do |bar|      bar.max = docs.length      bar.write    end     results = docs.map do |doc|      result = check_doc(doc, opts)      with_progress_bar(&:increment!)      result    end     process_results(results)  rescue Docs::DocNotFound => error    logger.error(error)    logger.info('Run "thor docs:list" to see the list of docs.')  end   private   def check_doc(doc, opts)    logger.debug("Checking #{doc.name}")     instance = doc.versions.first.new    scraper_version = instance.get_scraper_version(opts)    latest_version = instance.get_latest_version(opts)     {      name: doc.name,      scraper_version: format_version(scraper_version),      latest_version: format_version(latest_version),      outdated_state: instance.outdated_state(scraper_version, latest_version)    }  rescue NotImplementedError    logger.warn("Couldn't check #{doc.name}, get_latest_version is not implemented")    error_result(doc, '`get_latest_version` is not implemented')  rescue => error    logger.error("Error while checking #{doc.name}\n#{error.full_message.strip}")    error_result(doc, error.message.gsub(/'/, '`'))  end   def format_version(version)    str = version.to_s     # If the version is numeric and greater than or equal to 1e9 it's probably a timestamp    return str if str.match(/^(\d)+$/).nil? or str.to_i < 1e9     DateTime.strptime(str, '%s').strftime('%F')  end   def error_result(doc, reason)    {      name: doc.name,      error: reason    }  end   def process_results(results)    successful_results = results.select {|result| result.key?(:outdated_state)}    grouped_results = successful_results.group_by {|result| result[:outdated_state]}    failed_results = results.select {|result| result.key?(:error)}     log_results(grouped_results, failed_results)    upload_results(grouped_results, failed_results) if options[:upload]  end   #  # Result logging methods  #   def log_results(grouped_results, failed_results)    if options[:markdown]      puts all_results_to_markdown(grouped_results, failed_results)      return    end    log_failed_results(failed_results) unless failed_results.empty?    grouped_results.each do |label, results|      log_successful_results(label, results)    end  end   def log_successful_results(label, results)    title = "#{label} documentations (#{results.length})"    headings = ['Documentation', 'Scraper version', 'Latest version']    rows = results.map {|result| [result[:name], result[:scraper_version], result[:latest_version]]}     table = ::Terminal::Table.new :title => title, :headings => headings, :rows => rows    puts table  end   def log_failed_results(results)    title = "Documentations that could not be checked (#{results.length})"    headings = %w(Documentation Reason)    rows = results.map {|result| [result[:name], result[:error]]}     table = ::Terminal::Table.new :title => title, :headings => headings, :rows => rows    puts table  end   #  # Upload methods  #   def upload_results(grouped_results, failed_results)    # We can't create issues without a GitHub token    unless options.key?(:github_token)      logger.error("Please specify a GitHub token with the public_repo permission for #{UPLOAD_USER} with the --github-token parameter")      return    end     logger.info('Uploading the results to a new GitHub issue')     logger.info('Checking if the GitHub token belongs to the correct user')    user = github_get('/user')     # Only allow the DevDocs bot to upload reports    unless user['login'] == UPLOAD_USER      logger.error("Only #{UPLOAD_USER} is supposed to upload the results to a new issue. The specified github token is not for #{UPLOAD_USER}.")      return    end     logger.info('Creating a new GitHub issue')     issue = {      title: "Documentation versions report for #{Date.today.strftime('%B %Y')}",      body: all_results_to_markdown(grouped_results, failed_results)    }    created_issue = github_post("/repos/#{UPLOAD_REPO}/issues", issue)     logger.info('Checking if the previous issue is still open')     search_params = {      q: "Documentation versions report in:title author:#{UPLOAD_USER} is:issue repo:#{UPLOAD_REPO}",      sort: 'created',      order: 'desc'    }     matching_issues = github_get('/search/issues', **search_params)    previous_issue = matching_issues['items'].find {|item| item['number'] != created_issue['number']}     if previous_issue.nil?      logger.info('No previous issue found')      log_upload_success(created_issue)    else      logger.info('Commenting on the previous issue')       comment = "This report was superseded by ##{created_issue['number']}."      github_post("/repos/#{UPLOAD_REPO}/issues/#{previous_issue['number']}/comments", {body: comment})      if previous_issue['closed_at'].nil?        logger.info('Closing the previous issue')        github_patch("/repos/#{UPLOAD_REPO}/issues/#{previous_issue['number']}", {state: 'closed'})        log_upload_success(created_issue)      else        logger.info('The previous issue has already been closed')        log_upload_success(created_issue)      end    end  end   def all_results_to_markdown(grouped_results, failed_results)    all_results = []    grouped_results.each do |label, results|      all_results.push(successful_results_to_markdown(label, results))    end    all_results.push(failed_results_to_markdown(failed_results))     results_str = all_results.select {|result| !result.nil?}.join("\n\n")    travis_str = ENV['TRAVIS'].nil? ? '' : "\n\nThis issue was created by Travis CI build [##{ENV['TRAVIS_BUILD_NUMBER']}](#{ENV['TRAVIS_BUILD_WEB_URL']})."     body = <<-MARKDOWN## What is this? This is an automatically created issue which contains information about the version status of the documentations available on DevDocs. The results of this report can be used by maintainers when updating outdated documentations. Maintainers can close this issue when all documentations are up-to-date. The issue is also automatically closed when the next report is created.#{travis_str} ## Results     MARKDOWN    body.strip + "\n\n" + results_str  end   def successful_results_to_markdown(label, results)    return nil if results.empty?     title = "#{label} documentations (#{results.length})"    headings = ['Documentation', 'Scraper version', 'Latest version']    rows = results.map {|result| [result[:name], result[:scraper_version], result[:latest_version]]}     results_to_markdown(title, headings, rows)  end   def failed_results_to_markdown(results)    return nil if results.empty?     title = "Documentations that could not be checked (#{results.length})"    headings = %w(Documentation Reason)    rows = results.map {|result| [result[:name], result[:error]]}     results_to_markdown(title, headings, rows)  end   def results_to_markdown(title, headings, rows)    "<details>\n<summary>#{title}</summary>\n\n#{create_markdown_table(headings, rows)}\n</details>"  end   def create_markdown_table(headings, rows)    header = headings.join(' | ')    separator = '-|' * headings.length    body = rows.map {|row| row.join(' | ')}     header + "\n" + separator[0...-1] + "\n" + body.join("\n")  end   def log_upload_success(created_issue)    logger.info("Successfully uploaded the results to #{created_issue['html_url']}")  end   #  # HTTP utilities  #   def github_get(endpoint, **params)    github_request(endpoint, {method: :get, params: params})  end   def github_post(endpoint, params)    github_request(endpoint, {method: :post, body: params.to_json})  end   def github_patch(endpoint, params)    github_request(endpoint, {method: :patch, body: params.to_json})  end   def github_request(endpoint, opts)    url = "https://api.github.com#{endpoint}"     # GitHub token authentication    opts[:headers] = {      Authorization: "token #{options[:github_token]}"    }     # GitHub requires the Content-Type to be application/json when a body is passed    if opts.key?(:body)      opts[:headers]['Content-Type'] = 'application/json'    end     logger.debug("Making a #{opts[:method]} request to #{url}")    response = Docs::Request.run(url, opts)     # response.success? is false if the response code is 201    # GitHub returns 201 Created after an issue is created    if response.success? || response.code == 201      JSON.parse(response.body)    else      logger.error("Couldn't make a #{opts[:method]} request to #{url} (response code #{response.code})")      nil    end  end   # A utility method which ensures no progress bar is shown when stdout is not a tty  def with_progress_bar(&block)    return unless $stdout.tty?    @progress_bar ||= ::ProgressBar.new    block.call @progress_bar  end   def logger    @logger ||= Logger.new($stdout).tap do |logger|      logger.level = options[:verbose] ? Logger::DEBUG : Logger::INFO      logger.formatter = proc {|severity, datetime, progname, msg| "[#{severity}] #{msg}\n"}    end  endend