updates.thor 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315
  1. class UpdatesCLI < Thor
  2. # The GitHub user that is allowed to upload reports
  3. UPLOAD_USER = 'devdocs-bot'
  4. # The repository to create an issue in when uploading the results
  5. UPLOAD_REPO = 'freeCodeCamp/devdocs'
  6. def self.to_s
  7. 'Updates'
  8. end
  9. def initialize(*args)
  10. require 'docs'
  11. require 'progress_bar'
  12. require 'terminal-table'
  13. require 'date'
  14. super
  15. end
  16. desc 'check [--github-token] [--upload] [--verbose] [doc]...', 'Check for outdated documentations'
  17. option :github_token, :type => :string
  18. option :upload, :type => :boolean
  19. option :verbose, :type => :boolean
  20. def check(*names)
  21. # Convert names to a list of Scraper instances
  22. # Versions are omitted, if v10 is outdated than v8 is aswell
  23. docs = names.map {|name| Docs.find(name.split(/@|~/)[0], false)}.uniq
  24. # Check all documentations for updates when no arguments are given
  25. docs = Docs.all if docs.empty?
  26. opts = {
  27. logger: logger
  28. }
  29. if options.key?(:github_token)
  30. opts[:github_token] = options[:github_token]
  31. end
  32. with_progress_bar do |bar|
  33. bar.max = docs.length
  34. bar.write
  35. end
  36. results = docs.map do |doc|
  37. result = check_doc(doc, opts)
  38. with_progress_bar(&:increment!)
  39. result
  40. end
  41. process_results(results)
  42. rescue Docs::DocNotFound => error
  43. logger.error(error)
  44. logger.info('Run "thor docs:list" to see the list of docs.')
  45. end
  46. private
  47. def check_doc(doc, opts)
  48. logger.debug("Checking #{doc.name}")
  49. instance = doc.versions.first.new
  50. scraper_version = instance.get_scraper_version(opts)
  51. latest_version = instance.get_latest_version(opts)
  52. {
  53. name: doc.name,
  54. scraper_version: format_version(scraper_version),
  55. latest_version: format_version(latest_version),
  56. is_outdated: instance.is_outdated(scraper_version, latest_version)
  57. }
  58. rescue NotImplementedError
  59. logger.warn("Couldn't check #{doc.name}, get_latest_version is not implemented")
  60. error_result(doc, '`get_latest_version` is not implemented')
  61. rescue => error
  62. logger.error("Error while checking #{doc.name}\n#{error.full_message.strip}")
  63. error_result(doc, error.message.gsub(/'/, '`'))
  64. end
  65. def format_version(version)
  66. str = version.to_s
  67. # If the version is numeric and greater than or equal to 1e9 it's probably a timestamp
  68. return str if str.match(/^(\d)+$/).nil? or str.to_i < 1e9
  69. DateTime.strptime(str, '%s').strftime('%F')
  70. end
  71. def error_result(doc, reason)
  72. {
  73. name: doc.name,
  74. error: reason
  75. }
  76. end
  77. def process_results(results)
  78. successful_results = results.select {|result| result.key?(:is_outdated)}
  79. failed_results = results.select {|result| result.key?(:error)}
  80. up_to_date_results = successful_results.select {|result| !result[:is_outdated]}
  81. outdated_results = successful_results.select {|result| result[:is_outdated]}
  82. log_results(outdated_results, up_to_date_results, failed_results)
  83. upload_results(outdated_results, up_to_date_results, failed_results) if options[:upload]
  84. end
  85. #
  86. # Result logging methods
  87. #
  88. def log_results(outdated_results, up_to_date_results, failed_results)
  89. log_failed_results(failed_results) unless failed_results.empty?
  90. log_successful_results('Up-to-date', up_to_date_results) unless up_to_date_results.empty?
  91. log_successful_results('Outdated', outdated_results) unless outdated_results.empty?
  92. end
  93. def log_successful_results(label, results)
  94. title = "#{label} documentations (#{results.length})"
  95. headings = ['Documentation', 'Scraper version', 'Latest version']
  96. rows = results.map {|result| [result[:name], result[:scraper_version], result[:latest_version]]}
  97. table = Terminal::Table.new :title => title, :headings => headings, :rows => rows
  98. puts table
  99. end
  100. def log_failed_results(results)
  101. title = "Documentations that could not be checked (#{results.length})"
  102. headings = %w(Documentation Reason)
  103. rows = results.map {|result| [result[:name], result[:error]]}
  104. table = Terminal::Table.new :title => title, :headings => headings, :rows => rows
  105. puts table
  106. end
  107. #
  108. # Upload methods
  109. #
  110. def upload_results(outdated_results, up_to_date_results, failed_results)
  111. # We can't create issues without a GitHub token
  112. unless options.key?(:github_token)
  113. logger.error("Please specify a GitHub token with the public_repo permission for #{UPLOAD_USER} with the --github-token parameter")
  114. return
  115. end
  116. logger.info('Uploading the results to a new GitHub issue')
  117. logger.info('Checking if the GitHub token belongs to the correct user')
  118. user = github_get('/user')
  119. # Only allow the DevDocs bot to upload reports
  120. unless user['login'] == UPLOAD_USER
  121. logger.error("Only #{UPLOAD_USER} is supposed to upload the results to a new issue. The specified github token is not for #{UPLOAD_USER}.")
  122. return
  123. end
  124. logger.info('Creating a new GitHub issue')
  125. issue = results_to_issue(outdated_results, up_to_date_results, failed_results)
  126. created_issue = github_post("/repos/#{UPLOAD_REPO}/issues", issue)
  127. logger.info('Checking if the previous issue is still open')
  128. search_params = {
  129. q: "Documentation versions report in:title author:#{UPLOAD_USER} is:issue repo:#{UPLOAD_REPO}",
  130. sort: 'created',
  131. order: 'desc'
  132. }
  133. matching_issues = github_get('/search/issues', search_params)
  134. previous_issue = matching_issues['items'].find {|item| item['number'] != created_issue['number']}
  135. if previous_issue.nil?
  136. logger.info('No previous issue found')
  137. log_upload_success(created_issue)
  138. else
  139. logger.info('Commenting on the previous issue')
  140. comment = "This report was superseded by ##{created_issue['number']}."
  141. github_post("/repos/#{UPLOAD_REPO}/issues/#{previous_issue['number']}/comments", {body: comment})
  142. if previous_issue['closed_at'].nil?
  143. logger.info('Closing the previous issue')
  144. github_patch("/repos/#{UPLOAD_REPO}/issues/#{previous_issue['number']}", {state: 'closed'})
  145. log_upload_success(created_issue)
  146. else
  147. logger.info('The previous issue has already been closed')
  148. log_upload_success(created_issue)
  149. end
  150. end
  151. end
  152. def results_to_issue(outdated_results, up_to_date_results, failed_results)
  153. results = [
  154. successful_results_to_markdown('Outdated', outdated_results),
  155. successful_results_to_markdown('Up-to-date', up_to_date_results),
  156. failed_results_to_markdown(failed_results)
  157. ]
  158. results_str = results.select {|result| !result.nil?}.join("\n\n")
  159. travis_str = ENV['TRAVIS'].nil? ? '' : "\n\nThis issue was created by Travis CI build [##{ENV['TRAVIS_BUILD_NUMBER']}](#{ENV['TRAVIS_BUILD_WEB_URL']})."
  160. title = "Documentation versions report for #{Date.today.strftime('%B %Y')}"
  161. body = <<-MARKDOWN
  162. ## What is this?
  163. 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.
  164. 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}
  165. ## Results
  166. The #{outdated_results.length + up_to_date_results.length + failed_results.length} documentations are divided as follows:
  167. - #{outdated_results.length} that #{outdated_results.length == 1 ? 'is' : 'are'} outdated
  168. - #{up_to_date_results.length} that #{up_to_date_results.length == 1 ? 'is' : 'are'} up-to-date (patch updates are ignored)
  169. - #{failed_results.length} that could not be checked
  170. MARKDOWN
  171. {
  172. title: title,
  173. body: body.strip + "\n\n" + results_str
  174. }
  175. end
  176. def successful_results_to_markdown(label, results)
  177. return nil if results.empty?
  178. title = "#{label} documentations (#{results.length})"
  179. headings = ['Documentation', 'Scraper version', 'Latest version']
  180. rows = results.map {|result| [result[:name], result[:scraper_version], result[:latest_version]]}
  181. results_to_markdown(title, headings, rows)
  182. end
  183. def failed_results_to_markdown(results)
  184. return nil if results.empty?
  185. title = "Documentations that could not be checked (#{results.length})"
  186. headings = %w(Documentation Reason)
  187. rows = results.map {|result| [result[:name], result[:error]]}
  188. results_to_markdown(title, headings, rows)
  189. end
  190. def results_to_markdown(title, headings, rows)
  191. "<details>\n<summary>#{title}</summary>\n\n#{create_markdown_table(headings, rows)}\n</details>"
  192. end
  193. def create_markdown_table(headings, rows)
  194. header = headings.join(' | ')
  195. separator = '-|' * headings.length
  196. body = rows.map {|row| row.join(' | ')}
  197. header + "\n" + separator[0...-1] + "\n" + body.join("\n")
  198. end
  199. def log_upload_success(created_issue)
  200. logger.info("Successfully uploaded the results to #{created_issue['html_url']}")
  201. end
  202. #
  203. # HTTP utilities
  204. #
  205. def github_get(endpoint, params = {})
  206. github_request(endpoint, {method: :get, params: params})
  207. end
  208. def github_post(endpoint, params)
  209. github_request(endpoint, {method: :post, body: params.to_json})
  210. end
  211. def github_patch(endpoint, params)
  212. github_request(endpoint, {method: :patch, body: params.to_json})
  213. end
  214. def github_request(endpoint, opts)
  215. url = "https://api.github.com#{endpoint}"
  216. # GitHub token authentication
  217. opts[:headers] = {
  218. Authorization: "token #{options[:github_token]}"
  219. }
  220. # GitHub requires the Content-Type to be application/json when a body is passed
  221. if opts.key?(:body)
  222. opts[:headers]['Content-Type'] = 'application/json'
  223. end
  224. logger.debug("Making a #{opts[:method]} request to #{url}")
  225. response = Docs::Request.run(url, opts)
  226. # response.success? is false if the response code is 201
  227. # GitHub returns 201 Created after an issue is created
  228. if response.success? || response.code == 201
  229. JSON.parse(response.body)
  230. else
  231. logger.error("Couldn't make a #{opts[:method]} request to #{url} (response code #{response.code})")
  232. nil
  233. end
  234. end
  235. # A utility method which ensures no progress bar is shown when stdout is not a tty
  236. def with_progress_bar(&block)
  237. return unless $stdout.tty?
  238. @progress_bar ||= ::ProgressBar.new
  239. block.call @progress_bar
  240. end
  241. def logger
  242. @logger ||= Logger.new($stdout).tap do |logger|
  243. logger.level = options[:verbose] ? Logger::DEBUG : Logger::INFO
  244. logger.formatter = proc {|severity, datetime, progname, msg| "[#{severity}] #{msg}\n"}
  245. end
  246. end
  247. end