updates.thor 11 KB

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