updates.thor 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313
  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. outdated_state: instance.outdated_state(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?(:outdated_state)}
  80. grouped_results = successful_results.group_by {|result| result[:outdated_state]}
  81. failed_results = results.select {|result| result.key?(:error)}
  82. log_results(grouped_results, failed_results)
  83. upload_results(grouped_results, failed_results) if options[:upload]
  84. end
  85. #
  86. # Result logging methods
  87. #
  88. def log_results(grouped_results, failed_results)
  89. if options[:markdown]
  90. puts all_results_to_markdown(grouped_results, failed_results)
  91. return
  92. end
  93. log_failed_results(failed_results) unless failed_results.empty?
  94. grouped_results.each do |label, results|
  95. log_successful_results(label, results)
  96. end
  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(grouped_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(grouped_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(grouped_results, failed_results)
  161. all_results = []
  162. grouped_results.each do |label, results|
  163. all_results.push(successful_results_to_markdown(label, results))
  164. end
  165. all_results.push(failed_results_to_markdown(failed_results))
  166. results_str = all_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. MARKDOWN
  174. body.strip + "\n\n" + results_str
  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