livecheck.rb 15.1 KB
Newer Older
1
# typed: false
2
3
# frozen_string_literal: true

4
require "livecheck/strategy"
5
require "ruby-progressbar"
6
require "uri"
7

8
module Homebrew
9
10
  # The {Livecheck} module consists of methods used by the `brew livecheck`
  # command. These methods print the requested livecheck information
11
12
13
  # for formulae.
  #
  # @api private
14
15
16
  module Livecheck
    module_function

17
18
19
20
21
22
23
24
25
26
27
    GITEA_INSTANCES = %w[
      codeberg.org
      gitea.com
      opendev.org
      tildegit.org
    ].freeze

    GOGS_INSTANCES = %w[
      lolg.it
    ].freeze

28
29
30
31
32
33
34
35
36
37
38
    UNSTABLE_VERSION_KEYWORDS = %w[
      alpha
      beta
      bpo
      dev
      experimental
      prerelease
      preview
      rc
    ].freeze

39
40
41
    # Executes the livecheck logic for each formula in the `formulae_to_check` array
    # and prints the results.
    # @return [nil]
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
    def livecheck_formulae(formulae_to_check, args)
      # Identify any non-homebrew/core taps in use for current formulae
      non_core_taps = {}
      formulae_to_check.each do |f|
        next if f.tap.blank?
        next if f.tap.name == CoreTap.instance.name
        next if non_core_taps[f.tap.name]

        non_core_taps[f.tap.name] = f.tap
      end
      non_core_taps = non_core_taps.sort.to_h

      # Load additional Strategy files from taps
      non_core_taps.each_value do |tap|
        tap_strategy_path = "#{tap.path}/livecheck/strategy"
        Dir["#{tap_strategy_path}/*.rb"].sort.each(&method(:require)) if Dir.exist?(tap_strategy_path)
      end

      # Cache demodulized strategy names, to avoid repeating this work
      @livecheck_strategy_names = {}
      Strategy.constants.sort.each do |strategy_symbol|
        strategy = Strategy.const_get(strategy_symbol)
        @livecheck_strategy_names[strategy] = strategy.name.demodulize
      end
      @livecheck_strategy_names.freeze

      has_a_newer_upstream_version = false
69
70
71
72
73
74
75
76

      if args.json? && !args.quiet? && $stderr.tty?
        total_formulae = if formulae_to_check == Formula
          formulae_to_check.count
        else
          formulae_to_check.length
        end

77
78
79
80
        Tty.with($stderr) do |stderr|
          stderr.puts Formatter.headline("Running checks", color: :blue)
        end

81
82
83
84
85
86
87
88
89
        progress = ProgressBar.create(
          total:          total_formulae,
          progress_mark:  "#",
          remainder_mark: ".",
          format:         " %t: [%B] %c/%C ",
          output:         $stderr,
        )
      end

90
91
92
93
94
95
96
97
98
99
100
101
      formulae_checked = formulae_to_check.sort.map.with_index do |formula, i|
        if args.debug? && i.positive?
          puts <<~EOS

            ----------

          EOS
        end

        skip_result = skip_conditions(formula, args: args)
        next skip_result if skip_result != false

102
        formula.head&.downloader&.shutup!
103

104
105
106
107
108
        # Use the `stable` version for comparison except for installed
        # head-only formulae. A formula with `stable` and `head` that's
        # installed using `--head` will still use the `stable` version for
        # comparison.
        current = if formula.head_only?
Seeker's avatar
Seeker committed
109
          formula.any_installed_version.version.commit
110
        else
111
          formula.stable.version
112
        end
113

114
115
116
        latest = if formula.head_only?
          formula.head.downloader.fetch_last_commit
        else
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
          version_info = latest_version(formula, args: args)
          version_info[:latest] if version_info.present?
        end

        if latest.blank?
          no_versions_msg = "Unable to get versions"
          raise TypeError, no_versions_msg unless args.json?

          next version_info if version_info.is_a?(Hash) && version_info[:status] && version_info[:messages]

          next status_hash(formula, "error", [no_versions_msg], args: args)
        end

        if (m = latest.to_s.match(/(.*)-release$/)) && !current.to_s.match(/.*-release$/)
          latest = Version.new(m[1])
        end

134
        is_outdated = if formula.head_only?
135
136
          # A HEAD-only formula is considered outdated if the latest upstream
          # commit hash is different than the installed version's commit hash
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
          (current != latest)
        else
          (current < latest)
        end

        is_newer_than_upstream = formula.stable? && (current > latest)

        info = {
          formula: formula_name(formula, args: args),
          version: {
            current:             current.to_s,
            latest:              latest.to_s,
            outdated:            is_outdated,
            newer_than_upstream: is_newer_than_upstream,
          },
          meta:    {
            livecheckable: formula.livecheckable?,
          },
        }
156
        info[:meta][:head_only] = true if formula.head_only?
157
158
159
160
161
162
163
        info[:meta].merge!(version_info[:meta]) if version_info.present? && version_info.key?(:meta)

        next if args.newer_only? && !info[:version][:outdated]

        has_a_newer_upstream_version ||= true

        if args.json?
164
          progress&.increment
165
166
167
168
169
170
171
172
173
174
          info.except!(:meta) unless args.verbose?
          next info
        end

        print_latest_version(info, args: args)
        nil
      rescue => e
        Homebrew.failed = true

        if args.json?
175
          progress&.increment
176
177
178
179
180
181
182
          status_hash(formula, "error", [e.to_s], args: args)
        elsif !args.quiet?
          onoe "#{Tty.blue}#{formula_name(formula, args: args)}#{Tty.reset}: #{e}"
          nil
        end
      end

183
      if args.newer_only? && !has_a_newer_upstream_version && !args.debug? && !args.json?
184
185
186
        puts "No newer upstream versions."
      end

187
188
189
190
      return unless args.json?

      if progress
        progress.finish
191
192
193
        Tty.with($stderr) do |stderr|
          stderr.print "#{Tty.up}#{Tty.erase_line}" * 2
        end
194
195
196
      end

      puts JSON.generate(formulae_checked.compact)
197
198
    end

199
200
    # Returns the fully-qualified name of a formula if the `full_name` argument is
    # provided; returns the name otherwise.
201
    # @return [String]
202
203
204
205
206
207
208
209
210
    def formula_name(formula, args:)
      args.full_name? ? formula.full_name : formula.name
    end

    def status_hash(formula, status_str, messages = nil, args:)
      status_hash = {
        formula: formula_name(formula, args: args),
        status:  status_str,
      }
211
      status_hash[:messages] = messages if messages.is_a?(Array)
212
213
214
215
216

      if args.verbose?
        status_hash[:meta] = {
          livecheckable: formula.livecheckable?,
        }
217
        status_hash[:meta][:head_only] = true if formula.head_only?
218
219
220
221
222
      end

      status_hash
    end

223
    # If a formula has to be skipped, it prints or returns a Hash contaning the reason
224
    # for doing so; returns false otherwise.
225
    # @return [Hash, nil, Boolean]
226
227
228
229
    def skip_conditions(formula, args:)
      if formula.deprecated? && !formula.livecheckable?
        return status_hash(formula, "deprecated", args: args) if args.json?

Seeker's avatar
Seeker committed
230
231
        puts "#{Tty.red}#{formula_name(formula, args: args)}#{Tty.reset} : deprecated" unless args.quiet?
        return
232
233
      end

Sam Ford's avatar
Sam Ford committed
234
235
236
237
238
239
240
      if formula.disabled? && !formula.livecheckable?
        return status_hash(formula, "disabled", args: args) if args.json?

        puts "#{Tty.red}#{formula_name(formula, args: args)}#{Tty.reset} : disabled" unless args.quiet?
        return
      end

241
242
243
      if formula.versioned_formula? && !formula.livecheckable?
        return status_hash(formula, "versioned", args: args) if args.json?

Seeker's avatar
Seeker committed
244
245
        puts "#{Tty.red}#{formula_name(formula, args: args)}#{Tty.reset} : versioned" unless args.quiet?
        return
246
247
      end

248
      if formula.head_only? && !formula.any_version_installed?
249
250
251
        head_only_msg = "HEAD only formula must be installed to be livecheckable"
        return status_hash(formula, "error", [head_only_msg], args: args) if args.json?

Seeker's avatar
Seeker committed
252
253
        puts "#{Tty.red}#{formula_name(formula, args: args)}#{Tty.reset} : #{head_only_msg}" unless args.quiet?
        return
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
      end

      is_gist = formula.stable&.url&.include?("gist.github.com")
      if formula.livecheck.skip? || is_gist
        skip_msg = if formula.livecheck.skip_msg.is_a?(String) &&
                      formula.livecheck.skip_msg.present?
          formula.livecheck.skip_msg.to_s
        elsif is_gist
          "Stable URL is a GitHub Gist"
        else
          ""
        end

        return status_hash(formula, "skipped", (skip_msg.blank? ? nil : [skip_msg]), args: args) if args.json?

        unless args.quiet?
          puts "#{Tty.red}#{formula_name(formula, args: args)}#{Tty.reset} : skipped" \
              "#{" - #{skip_msg}" if skip_msg.present?}"
        end
Seeker's avatar
Seeker committed
273
        return
274
275
276
277
278
      end

      false
    end

279
280
    # Formats and prints the livecheck result for a formula.
    # @return [nil]
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
    def print_latest_version(info, args:)
      formula_s = "#{Tty.blue}#{info[:formula]}#{Tty.reset}"
      formula_s += " (guessed)" if !info[:meta][:livecheckable] && args.verbose?

      current_s = if info[:version][:newer_than_upstream]
        "#{Tty.red}#{info[:version][:current]}#{Tty.reset}"
      else
        info[:version][:current]
      end

      latest_s = if info[:version][:outdated]
        "#{Tty.green}#{info[:version][:latest]}#{Tty.reset}"
      else
        info[:version][:latest]
      end

      puts "#{formula_s} : #{current_s} ==> #{latest_s}"
    end

300
301
    # Returns an Array containing the formula URLs that can be used by livecheck.
    # @return [Array]
302
303
304
305
306
307
308
309
310
311
312
313
    def checkable_urls(formula)
      urls = []
      urls << formula.head.url if formula.head
      if formula.stable
        urls << formula.stable.url
        urls.concat(formula.stable.mirrors)
      end
      urls << formula.homepage if formula.homepage

      urls.compact
    end

314
315
    # Preprocesses and returns the URL used by livecheck.
    # @return [String]
316
    def preprocess_url(url)
317
318
319
320
321
322
      begin
        uri = URI.parse url
      rescue URI::InvalidURIError
        return url
      end

323
      host = uri.host == "github.s3.amazonaws.com" ? "github.com" : uri.host
324
      path = uri.path.delete_prefix("/").delete_suffix(".git")
325
      scheme = uri.scheme
326

327
      if host.end_with?("github.com")
328
        return url if path.match? %r{/releases/latest/?$}
329

330
331
        owner, repo = path.delete_prefix("downloads/").split("/")
        url = "#{scheme}://#{host}/#{owner}/#{repo}.git"
332
      elsif host.end_with?(*GITEA_INSTANCES)
333
334
335
        return url if path.match? %r{/releases/latest/?$}

        owner, repo = path.split("/")
336
        url = "#{scheme}://#{host}/#{owner}/#{repo}.git"
337
      elsif host.end_with?(*GOGS_INSTANCES)
338
339
340
        owner, repo = path.split("/")
        url = "#{scheme}://#{host}/#{owner}/#{repo}.git"
      # sourcehut
341
      elsif host.end_with?("git.sr.ht")
342
        owner, repo = path.split("/")
343
        url = "#{scheme}://#{host}/#{owner}/#{repo}"
Sam Ford's avatar
Sam Ford committed
344
      # GitLab (gitlab.com or self-hosted)
345
346
      elsif path.include?("/-/archive/")
        url = url.sub(%r{/-/archive/.*$}i, ".git")
347
348
349
350
351
      end

      url
    end

352
353
354
    # Identifies the latest version of the formula and returns a Hash containing
    # the version information. Returns nil if a latest version couldn't be found.
    # @return [Hash, nil]
355
356
357
358
359
360
361
362
363
364
365
366
367
    def latest_version(formula, args:)
      has_livecheckable = formula.livecheckable?
      livecheck = formula.livecheck
      livecheck_regex = livecheck.regex
      livecheck_strategy = livecheck.strategy
      livecheck_url = livecheck.url

      urls = [livecheck_url] if livecheck_url.present?
      urls ||= checkable_urls(formula)

      if args.debug?
        puts
        puts "Formula:          #{formula_name(formula, args: args)}"
368
        puts "Head only?:       true" if formula.head_only?
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
        puts "Livecheckable?:   #{has_livecheckable ? "Yes" : "No"}"
      end

      urls.each_with_index do |original_url, i|
        if args.debug?
          puts
          puts "URL:              #{original_url}"
        end

        # Skip Gists until/unless we create a method of identifying revisions
        if original_url.include?("gist.github.com")
          odebug "Skipping: GitHub Gists are not supported"
          next
        end

        # Do not preprocess the URL when livecheck.strategy is set to :page_match
        url = if livecheck_strategy == :page_match
          original_url
        else
          preprocess_url(original_url)
        end

        strategies = Strategy.from_url(url, livecheck_regex.present?)
        strategy = Strategy.from_symbol(livecheck_strategy)
        strategy ||= strategies.first
        strategy_name = @livecheck_strategy_names[strategy]

        if args.debug?
          puts "URL (processed):  #{url}" if url != original_url
          if strategies.present? && args.verbose?
            puts "Strategies:       #{strategies.map { |s| @livecheck_strategy_names[s] }.join(", ")}"
          end
          puts "Strategy:         #{strategy.blank? ? "None" : strategy_name}"
          puts "Regex:            #{livecheck_regex.inspect}" if livecheck_regex.present?
        end

        if livecheck_strategy == :page_match && livecheck_regex.blank?
          odebug "#{strategy_name} strategy requires a regex"
          next
        end

        if livecheck_strategy.present? && !strategies.include?(strategy)
          odebug "#{strategy_name} strategy does not apply to this URL"
          next
        end

        next if strategy.blank?

        strategy_data = strategy.find_versions(url, livecheck_regex)
        match_version_map = strategy_data[:matches]
        regex = strategy_data[:regex]

        if strategy_data[:messages].is_a?(Array) && match_version_map.blank?
          puts strategy_data[:messages] unless args.json?
          next if i + 1 < urls.length

          return status_hash(formula, "error", strategy_data[:messages], args: args)
        end

        if args.debug?
          puts "URL (strategy):   #{strategy_data[:url]}" if strategy_data[:url] != url
          puts "Regex (strategy): #{strategy_data[:regex].inspect}" if strategy_data[:regex] != livecheck_regex
        end

        match_version_map.delete_if do |_match, version|
          next true if version.blank?
          next false if has_livecheckable

          UNSTABLE_VERSION_KEYWORDS.any? do |rejection|
            version.to_s.include?(rejection)
          end
        end

        if args.debug? && match_version_map.present?
          puts
          puts "Matched Versions:"

          if args.verbose?
            match_version_map.each do |match, version|
              puts "#{match} => #{version.inspect}"
            end
          else
            puts match_version_map.values.join(", ")
          end
        end

        next if match_version_map.blank?

        version_info = {
          latest: Version.new(match_version_map.values.max),
        }

        if args.json? && args.verbose?
          version_info[:meta] = {
            url:      {
              original: original_url,
            },
            strategy: strategy.blank? ? nil : strategy_name,
          }
          version_info[:meta][:url][:processed] = url if url != original_url
          version_info[:meta][:url][:strategy] = strategy_data[:url] if strategy_data[:url] != url
          if strategies.present?
            version_info[:meta][:strategies] = strategies.map { |s| @livecheck_strategy_names[s] }
          end
          version_info[:meta][:regex] = regex.inspect if regex.present?
        end

        return version_info
      end

      nil
    end
  end
end