History

Check for updates based on modified date for scrapers without release

Jasper van Merle committed 7 years ago

Commit cd436321 parent 6ee169320 files · +186 −115Browse files

Unified · Split

20 files
  1. docs/Scraper-Reference.md +1 −1
  2. lib/docs/core/doc.rb +103 −1
  3. lib/docs/core/scraper.rb +0 −85
  4. lib/docs/scrapers/c.rb +8 −0
  5. lib/docs/scrapers/chef.rb +2 −3
  6. lib/docs/scrapers/cpp.rb +9 −0
  7. lib/docs/scrapers/haskell.rb +5 −4
  8. lib/docs/scrapers/http.rb +2 −0
  9. lib/docs/scrapers/markdown.rb +4 −0
  10. lib/docs/scrapers/mdn/css.rb +2 −0
  11. lib/docs/scrapers/mdn/dom.rb +2 −0
  12. lib/docs/scrapers/mdn/dom_events.rb +2 −0
  13. lib/docs/scrapers/mdn/html.rb +2 −0
  14. lib/docs/scrapers/mdn/javascript.rb +2 −0
  15. lib/docs/scrapers/mdn/mdn.rb +6 −0
  16. lib/docs/scrapers/mdn/svg.rb +2 −0
  17. lib/docs/scrapers/mdn/xslt_xpath.rb +2 −0
  18. lib/docs/scrapers/openjdk.rb +1 −1
  19. lib/docs/scrapers/support_tables.rb +7 −0
  20. lib/tasks/updates.thor +24 −20
docs/Scraper-Reference.md+1−1
@@ -187,7 +187,7 @@ More information about how filters work is available on the [Filter Reference](. · 186 unmodified lines
187 187
188## Keeping scrapers up-to-date188## Keeping scrapers up-to-date
189 189
190In order to keep scrapers up-to-date the `get_latest_version(options, &block)` method should be overridden by all scrapers that define the `self.release` attribute. This method should return the latest version of the documentation that is being scraped. The result of this method is periodically reported in a "Documentation versions report" issue which helps maintainers keep track of outdated documentations.190In order to keep scrapers up-to-date the `get_latest_version(options, &block)` method should be overridden. If `self.release` is defined, this should return the latest version of the documentation. If `self.release` is not defined, it should return the Epoch time when the documentation was last modified. If the documentation will never change, simply return `1.0.0`. The result of this method is periodically reported in a "Documentation versions report" issue which helps maintainers keep track of outdated documentations.
191 191
192To make life easier, there are a few utility methods that you can use in `get_latest_version`:192To make life easier, there are a few utility methods that you can use in `get_latest_version`:
193* `fetch(url, options, &block)`193* `fetch(url, options, &block)`
lib/docs/core/doc.rb+103−1
@@ -152,7 +152,6 @@ module Docs · 151 unmodified lines
152 end152 end
153 end153 end
154 154
155
156 def initialize155 def initialize
157 raise NotImplementedError, "#{self.class} is an abstract class and cannot be instantiated." if self.class.abstract156 raise NotImplementedError, "#{self.class} is an abstract class and cannot be instantiated." if self.class.abstract
158 end157 end
@@ -164,5 +163,108 @@ module Docs · 5 unmodified lines
164 def build_pages(&block)163 def build_pages(&block)
165 raise NotImplementedError164 raise NotImplementedError
166 end165 end
166
167 def get_scraper_version(opts, &block)
168 if self.class.method_defined?(:options) and !options[:release].nil?
169 block.call options[:release]
170 else
171 # If options[:release] does not exist, we return the Epoch timestamp of when the doc was last modified in DevDocs production
172 fetch_json('https://devdocs.io/docs.json', opts) do |json|
173 items = json.select {|item| item['name'] == self.class.name}
174 items = items.map {|item| item['mtime']}
175 block.call items.max
176 end
177 end
178 end
179
180 # Should return the latest version of this documentation
181 # If options[:release] is defined, it should be in the same format
182 # If options[:release] is not defined, it should return the Epoch timestamp of when the documentation was last updated
183 # If the docs will never change, simply return '1.0.0'
184 def get_latest_version(options, &block)
185 raise NotImplementedError
186 end
187
188 # Returns whether or not this scraper is outdated.
189 #
190 # The default implementation assumes the documentation uses a semver(-like) approach when it comes to versions.
191 # Patch updates are ignored because there are usually little to no documentation changes in bug-fix-only releases.
192 #
193 # Scrapers of documentations that do not use this versioning approach should override this method.
194 #
195 # Examples of the default implementation:
196 # 1 -> 2 = outdated
197 # 1.1 -> 1.2 = outdated
198 # 1.1.1 -> 1.1.2 = not outdated
199 def is_outdated(scraper_version, latest_version)
200 scraper_parts = scraper_version.to_s.split(/\./).map(&:to_i)
201 latest_parts = latest_version.to_s.split(/\./).map(&:to_i)
202
203 # Only check the first two parts, the third part is for patch updates
204 [0, 1].each do |i|
205 break if i >= scraper_parts.length or i >= latest_parts.length
206 return true if latest_parts[i] > scraper_parts[i]
207 return false if latest_parts[i] < scraper_parts[i]
208 end
209
210 false
211 end
212
213 private
214
215 #
216 # Utility methods for get_latest_version
217 #
218
219 def fetch(url, options, &block)
220 headers = {}
221
222 if options.key?(:github_token) and url.start_with?('https://api.github.com/')
223 headers['Authorization'] = "token #{options[:github_token]}"
224 end
225
226 options[:logger].debug("Fetching #{url}")
227
228 Request.run(url, { headers: headers }) do |response|
229 if response.success?
230 block.call response.body
231 else
232 options[:logger].error("Couldn't fetch #{url} (response code #{response.code})")
233 block.call nil
234 end
235 end
236 end
237
238 def fetch_doc(url, options, &block)
239 fetch(url, options) do |body|
240 block.call Nokogiri::HTML.parse(body, nil, 'UTF-8')
241 end
242 end
243
244 def fetch_json(url, options, &block)
245 fetch(url, options) do |body|
246 block.call JSON.parse(body)
247 end
248 end
249
250 def get_npm_version(package, options, &block)
251 fetch_json("https://registry.npmjs.com/#{package}", options) do |json|
252 block.call json['dist-tags']['latest']
253 end
254 end
255
256 def get_latest_github_release(owner, repo, options, &block)
257 fetch_json("https://api.github.com/repos/#{owner}/#{repo}/releases/latest", options, &block)
258 end
259
260 def get_github_tags(owner, repo, options, &block)
261 fetch_json("https://api.github.com/repos/#{owner}/#{repo}/tags", options, &block)
262 end
263
264 def get_github_file_contents(owner, repo, path, options, &block)
265 fetch_json("https://api.github.com/repos/#{owner}/#{repo}/contents/#{path}", options) do |json|
266 block.call(Base64.decode64(json['content']))
267 end
268 end
167 end269 end
168end270end
lib/docs/core/scraper.rb+0−85
@@ -132,35 +132,6 @@ module Docs · 131 unmodified lines
132 end132 end
133 end133 end
134 134
135 def get_latest_version(options, &block)
136 raise NotImplementedError
137 end
138
139 # Returns whether or not this scraper is outdated.
140 #
141 # The default implementation assumes the documentation uses a semver(-like) approach when it comes to versions.
142 # Patch updates are ignored because there are usually little to no documentation changes in bug-fix-only releases.
143 #
144 # Scrapers of documentations that do not use this versioning approach should override this method.
145 #
146 # Examples of the default implementation:
147 # 1 -> 2 = outdated
148 # 1.1 -> 1.2 = outdated
149 # 1.1.1 -> 1.1.2 = not outdated
150 def is_outdated(scraper_version, latest_version)
151 scraper_parts = scraper_version.split(/\./).map(&:to_i)
152 latest_parts = latest_version.split(/\./).map(&:to_i)
153
154 # Only check the first two parts, the third part is for patch updates
155 [0, 1].each do |i|
156 break if i >= scraper_parts.length or i >= latest_parts.length
157 return true if latest_parts[i] > scraper_parts[i]
158 return false if latest_parts[i] < scraper_parts[i]
159 end
160
161 false
162 end
163
164 private135 private
165 136
166 def request_one(url)137 def request_one(url)
@@ -231,62 +202,6 @@ module Docs · 64 unmodified lines
231 {}202 {}
232 end203 end
233 204
234 #
235 # Utility methods for get_latest_version
236 #
237
238 def fetch(url, options, &block)
239 headers = {}
240
241 if options.key?(:github_token) and url.start_with?('https://api.github.com/')
242 headers['Authorization'] = "token #{options[:github_token]}"
243 end
244
245 options[:logger].debug("Fetching #{url}")
246
247 Request.run(url, { headers: headers }) do |response|
248 if response.success?
249 block.call response.body
250 else
251 options[:logger].error("Couldn't fetch #{url} (response code #{response.code})")
252 block.call nil
253 end
254 end
255 end
256
257 def fetch_doc(url, options, &block)
258 fetch(url, options) do |body|
259 block.call Nokogiri::HTML.parse body, nil, 'UTF-8'
260 end
261 end
262
263 def fetch_json(url, options, &block)
264 fetch(url, options) do |body|
265 json = JSON.parse(body)
266 block.call json
267 end
268 end
269
270 def get_npm_version(package, options, &block)
271 fetch_json("https://registry.npmjs.com/#{package}", options) do |json|
272 block.call json['dist-tags']['latest']
273 end
274 end
275
276 def get_latest_github_release(owner, repo, options, &block)
277 fetch_json("https://api.github.com/repos/#{owner}/#{repo}/releases/latest", options, &block)
278 end
279
280 def get_github_tags(owner, repo, options, &block)
281 fetch_json("https://api.github.com/repos/#{owner}/#{repo}/tags", options, &block)
282 end
283
284 def get_github_file_contents(owner, repo, path, options, &block)
285 fetch_json("https://api.github.com/repos/#{owner}/#{repo}/contents/#{path}", options) do |json|
286 block.call(Base64.decode64(json['content']))
287 end
288 end
289
290 module FixInternalUrlsBehavior205 module FixInternalUrlsBehavior
291 def self.included(base)206 def self.included(base)
292 base.extend ClassMethods207 base.extend ClassMethods
lib/docs/scrapers/c.rb+8−0
@@ -26,6 +26,14 @@ module Docs · 25 unmodified lines
26 Licensed under the Creative Commons Attribution-ShareAlike Unported License v3.0.26 Licensed under the Creative Commons Attribution-ShareAlike Unported License v3.0.
27 HTML27 HTML
28 28
29 def get_latest_version(options, &block)
30 fetch_doc('https://en.cppreference.com/w/Cppreference:Archives', options) do |doc|
31 link = doc.at_css('a[title^="File:"]')
32 date = link.content.scan(/(\d+)\./)[0][0]
33 block.call DateTime.strptime(date, '%Y%m%d').to_time.to_i
34 end
35 end
36
29 private37 private
30 38
31 def file_path_for(*)39 def file_path_for(*)
lib/docs/scrapers/chef.rb+2−3
@@ -49,9 +49,8 @@ module Docs · 48 unmodified lines
49 end49 end
50 50
51 def get_latest_version(options, &block)51 def get_latest_version(options, &block)
52 fetch_doc('https://docs-archive.chef.io/', options) do |doc|52 fetch_doc('https://downloads.chef.io/chef', options) do |doc|
53 cell = doc.at_css('.main-archives > tr:nth-child(2) > td:nth-child(2)')53 block.call doc.at_css('h1.product-heading > span').content.strip
54 block.call cell.content.sub(/Chef Client /, '')
55 end54 end
56 end55 end
57 end56 end
lib/docs/scrapers/cpp.rb+9−0
@@ -34,6 +34,15 @@ module Docs · 33 unmodified lines
34 Licensed under the Creative Commons Attribution-ShareAlike Unported License v3.0.34 Licensed under the Creative Commons Attribution-ShareAlike Unported License v3.0.
35 HTML35 HTML
36 36
37 # Same as get_latest_version in lib/docs/scrapers/c.rb
38 def get_latest_version(options, &block)
39 fetch_doc('https://en.cppreference.com/w/Cppreference:Archives', options) do |doc|
40 link = doc.at_css('a[title^="File:"]')
41 date = link.content.scan(/(\d+)\./)[0][0]
42 block.call DateTime.strptime(date, '%Y%m%d').to_time.to_i
43 end
44 end
45
37 private46 private
38 47
39 def file_path_for(*)48 def file_path_for(*)
lib/docs/scrapers/haskell.rb+5−4
@@ -10,7 +10,7 @@ module Docs · 9 unmodified lines
10 10
11 html_filters.push 'haskell/entries', 'haskell/clean_html'11 html_filters.push 'haskell/entries', 'haskell/clean_html'
12 12
13 options[:container] = ->(filter) { filter.subpath.start_with?('users_guide') ? '.body' : '#content' }13 options[:container] = ->(filter) {filter.subpath.start_with?('users_guide') ? '.body' : '#content'}
14 14
15 options[:only_patterns] = [/\Alibraries\//, /\Ausers_guide\//]15 options[:only_patterns] = [/\Alibraries\//, /\Ausers_guide\//]
16 options[:skip_patterns] = [16 options[:skip_patterns] = [
@@ -70,9 +70,10 @@ module Docs · 53 unmodified lines
70 end70 end
71 71
72 def get_latest_version(options, &block)72 def get_latest_version(options, &block)
73 fetch_doc('https://downloads.haskell.org/~ghc/latest/docs/html/users_guide/', options) do |doc|73 fetch_doc('https://downloads.haskell.org/~ghc/latest/docs/html/', options) do |doc|
74 label = doc.at_css('.related > ul > li:last-child').content74 links = doc.css('a').to_a
75 block.call label.scan(/([0-9.]+)/)[0][0]75 versions = links.map {|link| link['href'].scan(/ghc-([0-9.]+)/)}
76 block.call versions.find {|version| !version.empty?}[0][0]
76 end77 end
77 end78 end
78 end79 end
lib/docs/scrapers/http.rb+2−0
@@ -7,6 +7,8 @@ module Docs · 6 unmodified lines
7 7
8 html_filters.push 'http/clean_html', 'http/entries', 'title'8 html_filters.push 'http/clean_html', 'http/entries', 'title'
9 9
10 options[:mdn_tag] = 'HTTP'
11
10 options[:root_title] = 'HTTP'12 options[:root_title] = 'HTTP'
11 options[:title] = ->(filter) { filter.current_url.host == 'tools.ietf.org' ? false : filter.default_title }13 options[:title] = ->(filter) { filter.current_url.host == 'tools.ietf.org' ? false : filter.default_title }
12 options[:container] = ->(filter) { filter.current_url.host == 'tools.ietf.org' ? '.content' : nil }14 options[:container] = ->(filter) { filter.current_url.host == 'tools.ietf.org' ? '.content' : nil }
lib/docs/scrapers/markdown.rb+4−0
@@ -13,5 +13,9 @@ module Docs · 12 unmodified lines
13 &copy; 2004 John Gruber<br>13 &copy; 2004 John Gruber<br>
14 Licensed under the BSD License.14 Licensed under the BSD License.
15 HTML15 HTML
16
17 def get_latest_version(options, &block)
18 block.call '1.0.0'
19 end
16 end20 end
17end21end
lib/docs/scrapers/mdn/css.rb+2−0
@@ -6,6 +6,8 @@ module Docs · 5 unmodified lines
6 6
7 html_filters.push 'css/clean_html', 'css/entries', 'title'7 html_filters.push 'css/clean_html', 'css/entries', 'title'
8 8
9 options[:mdn_tag] = 'CSS'
10
9 options[:root_title] = 'CSS'11 options[:root_title] = 'CSS'
10 12
11 options[:skip] = %w(/CSS3 /Media/Visual /paged_media /Media/TV /Media/Tactile)13 options[:skip] = %w(/CSS3 /Media/Visual /paged_media /Media/TV /Media/Tactile)
lib/docs/scrapers/mdn/dom.rb+2−0
@@ -8,6 +8,8 @@ module Docs · 7 unmodified lines
8 8
9 html_filters.push 'dom/clean_html', 'dom/entries', 'title'9 html_filters.push 'dom/clean_html', 'dom/entries', 'title'
10 10
11 options[:mdn_tag] = 'XSLT_Reference'
12
11 options[:root_title] = 'DOM'13 options[:root_title] = 'DOM'
12 14
13 options[:skip] = %w(15 options[:skip] = %w(
lib/docs/scrapers/mdn/dom_events.rb+2−0
@@ -9,6 +9,8 @@ module Docs · 8 unmodified lines
9 html_filters.insert_after 'clean_html', 'dom_events/clean_html'9 html_filters.insert_after 'clean_html', 'dom_events/clean_html'
10 html_filters.push 'dom_events/entries', 'title'10 html_filters.push 'dom_events/entries', 'title'
11 11
12 options[:mdn_tag] = 'events'
13
12 options[:root_title] = 'DOM Events'14 options[:root_title] = 'DOM Events'
13 15
14 options[:skip] = %w(/MozOrientation)16 options[:skip] = %w(/MozOrientation)
lib/docs/scrapers/mdn/html.rb+2−0
@@ -7,6 +7,8 @@ module Docs · 6 unmodified lines
7 7
8 html_filters.push 'html/clean_html', 'html/entries', 'title'8 html_filters.push 'html/clean_html', 'html/entries', 'title'
9 9
10 options[:mdn_tag] = 'HTML'
11
10 options[:root_title] = 'HTML'12 options[:root_title] = 'HTML'
11 13
12 options[:title] = ->(filter) do14 options[:title] = ->(filter) do
lib/docs/scrapers/mdn/javascript.rb+2−0
@@ -8,6 +8,8 @@ module Docs · 7 unmodified lines
8 8
9 html_filters.push 'javascript/clean_html', 'javascript/entries', 'title'9 html_filters.push 'javascript/clean_html', 'javascript/entries', 'title'
10 10
11 options[:mdn_tag] = 'JavaScript'
12
11 options[:root_title] = 'JavaScript'13 options[:root_title] = 'JavaScript'
12 14
13 # Don't want15 # Don't want
lib/docs/scrapers/mdn/mdn.rb+6−0
@@ -21,6 +21,12 @@ module Docs · 20 unmodified lines
21 Licensed under the Creative Commons Attribution-ShareAlike License v2.5 or later.21 Licensed under the Creative Commons Attribution-ShareAlike License v2.5 or later.
22 HTML22 HTML
23 23
24 def get_latest_version(opts, &block)
25 fetch_json("https://developer.mozilla.org/en-US/docs/feeds/json/tag/#{options[:mdn_tag]}", opts) do |json|
26 block.call DateTime.parse(json[0]['pubdate']).to_time.to_i
27 end
28 end
29
24 private30 private
25 31
26 def process_response?(response)32 def process_response?(response)
lib/docs/scrapers/mdn/svg.rb+2−0
@@ -8,6 +8,8 @@ module Docs · 7 unmodified lines
8 8
9 html_filters.push 'svg/clean_html', 'svg/entries', 'title'9 html_filters.push 'svg/clean_html', 'svg/entries', 'title'
10 10
11 options[:mdn_tag] = 'XSLT_Reference'
12
11 options[:root_title] = 'SVG'13 options[:root_title] = 'SVG'
12 14
13 options[:title] = ->(filter) do15 options[:title] = ->(filter) do
lib/docs/scrapers/mdn/xslt_xpath.rb+2−0
@@ -8,6 +8,8 @@ module Docs · 7 unmodified lines
8 8
9 html_filters.push 'xslt_xpath/clean_html', 'xslt_xpath/entries', 'title'9 html_filters.push 'xslt_xpath/clean_html', 'xslt_xpath/entries', 'title'
10 10
11 options[:mdn_tag] = 'XSLT_Reference'
12
11 options[:root_title] = 'XSLT'13 options[:root_title] = 'XSLT'
12 14
13 options[:only_patterns] = [/\A\/XSLT/, /\A\/XPath/]15 options[:only_patterns] = [/\A\/XSLT/, /\A\/XPath/]
lib/docs/scrapers/openjdk.rb+1−1
@@ -105,7 +105,7 @@ module Docs · 104 unmodified lines
105 end105 end
106 end106 end
107 107
108 block.call latest_version.to_s108 block.call latest_version
109 end109 end
110 end110 end
111end111end
lib/docs/scrapers/support_tables.rb+7−0
@@ -178,5 +178,12 @@ module Docs · 177 unmodified lines
178 </p>178 </p>
179 </div>179 </div>
180 HTML180 HTML
181
182 def get_latest_version(options, &block)
183 fetch('https://feeds.feedburner.com/WhenCanIUse?format=xml', options) do |body|
184 timestamp = body.scan(/<updated>([^<]+)<\/updated>/)[0][0]
185 block.call DateTime.parse(timestamp).to_time.to_i
186 end
187 end
181 end188 end
182end189end
lib/tasks/updates.thor+24−20
@@ -1,7 +1,7 @@
1class UpdatesCLI < Thor1class UpdatesCLI < Thor
2 # The GitHub user that is allowed to upload reports2 # The GitHub user that is allowed to upload reports
3 # TODO: Update this before creating a PR3 # TODO: Update this before creating a PR
4 UPLOAD_USER = 'jmerle'4 UPLOAD_USER = 'devdocs-bot'
5 5
6 # The repository to create an issue in when uploading the results6 # The repository to create an issue in when uploading the results
7 # TODO: Update this before creating a PR7 # TODO: Update this before creating a PR
@@ -59,25 +59,19 @@ class UpdatesCLI < Thor · 51 unmodified lines
59 private59 private
60 60
61 def check_doc(doc, opts)61 def check_doc(doc, opts)
62 # Newer scraper versions always come before older scraper versions
63 # Therefore, the first item's release value is the latest scraper version
64 #
65 # For example, a scraper could scrape 3 versions: 10, 11 and 12
66 # doc.versions.first would be the scraper for version 12
67 instance = doc.versions.first.new
68
69 scraper_version = instance.class.method_defined?(:options) ? instance.options[:release] : nil
70 return error_result(doc, '`options[:release]` does not exist') if scraper_version.nil?
71
72 logger.debug("Checking #{doc.name}")62 logger.debug("Checking #{doc.name}")
73 63
74 instance.get_latest_version(opts) do |latest_version|64 instance = doc.versions.first.new
75 return {65
76 name: doc.name,66 instance.get_scraper_version(opts) do |scraper_version|
77 scraper_version: scraper_version,67 instance.get_latest_version(opts) do |latest_version|
78 latest_version: latest_version,68 return {
79 is_outdated: instance.is_outdated(scraper_version, latest_version)69 name: doc.name,
80 }70 scraper_version: format_version(scraper_version),
71 latest_version: format_version(latest_version),
72 is_outdated: instance.is_outdated(scraper_version, latest_version)
73 }
74 end
81 end75 end
82 rescue NotImplementedError76 rescue NotImplementedError
83 logger.warn("Couldn't check #{doc.name}, get_latest_version is not implemented")77 logger.warn("Couldn't check #{doc.name}, get_latest_version is not implemented")
@@ -87,6 +81,15 @@ class UpdatesCLI < Thor · 3 unmodified lines
87 raise81 raise
88 end82 end
89 83
84 def format_version(version)
85 str = version.to_s
86
87 # If the version is numeric and greater than or equal to 1e9 it's probably a timestamp
88 return str if str.match(/^(\d)+$/).nil? or str.to_i < 1e9
89
90 DateTime.strptime(str, '%s').strftime('%B %-d, %Y')
91 end
92
90 def error_result(doc, reason)93 def error_result(doc, reason)
91 {94 {
92 name: doc.name,95 name: doc.name,
@@ -199,14 +202,15 @@ class UpdatesCLI < Thor · 106 unmodified lines
199 ]202 ]
200 203
201 results_str = results.select {|result| !result.nil?}.join("\n\n")204 results_str = results.select {|result| !result.nil?}.join("\n\n")
205 travis_str = ENV['TRAVIS'].nil? ? '' : "\n\nThis issue was created by Travis CI build [##{ENV['TRAVIS_BUILD_NUMBER']}](#{ENV['TRAVIS_BUILD_WEB_URL']})."
202 206
203 title = "Documentation versions report for #{Date.today.strftime('%B')} 2019"207 title = "Documentation versions report for #{Date.today.strftime('%B %Y')}"
204 body = <<-MARKDOWN208 body = <<-MARKDOWN
205## What is this?209## What is this?
206 210
207This 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.211This 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.
208 212
209Maintainers can close this issue when all documentations are up-to-date. This issue is automatically closed when the next report is created.213Maintainers can close this issue when all documentations are up-to-date. This issue is automatically closed when the next report is created.#{travis_str}
210 214
211## Results215## Results
212 216

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t