updates.thor 11 KB

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