updates.thor 11 KB

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