Sam Ford 56dd89114d
Standardize valid strategy block return types
Valid `strategy` block return types currently vary between
strategies. Some only accept a string whereas others accept a string
or array of strings. [`strategy` blocks also accept a `nil` return
(to simplify early returns) but this was already standardized across
strategies.]

While some strategies only identify one version by default (where a
string is an appropriate return type), it could be that a strategy
block identifies more than one version. In this situation, the
strategy would need to be modified to accept (and work with) an
array from a `strategy` block.

Rather than waiting for this to become a problem, this modifies all
strategies to standardize on allowing `strategy` blocks to return a
string or array of strings (even if only one of these is currently
used in practice). Standardizing valid return types helps to further
simplify the mental model for `strategy` blocks and reduce cognitive
load.

This commit extracts related logic from `#find_versions` into
methods like `#versions_from_content`, which is conceptually similar
to `PageMatch#page_matches` (renamed to `#versions_from_content`
for consistency). This allows us to write tests for the related code
without having to make network requests (or stub them) at this point.
In general, this also helps to better align the structure of
strategies and how the various `#find_versions` methods work with
versions.

There's still more planned work to be done here but this is a step
in the right direction.
2021-08-11 19:05:57 -04:00

156 lines
5.8 KiB
Ruby

# typed: false
# frozen_string_literal: true
require "open3"
module Homebrew
module Livecheck
module Strategy
# The {Git} strategy identifies versions of software in a Git repository
# by checking the tags using `git ls-remote --tags`.
#
# Livecheck has historically prioritized the {Git} strategy over others
# and this behavior was continued when the priority setup was created.
# This is partly related to Livecheck checking formula URLs in order of
# `head`, `stable`, and then `homepage`. The higher priority here may
# be removed (or altered) in the future if we reevaluate this particular
# behavior.
#
# This strategy does not have a default regex. Instead, it simply removes
# any non-digit text from the start of tags and parses the rest as a
# {Version}. This works for some simple situations but even one unusual
# tag can cause a bad result. It's better to provide a regex in a
# `livecheck` block, so `livecheck` only matches what we really want.
#
# @api public
class Git
extend T::Sig
# The priority of the strategy on an informal scale of 1 to 10 (from
# lowest to highest).
PRIORITY = 8
# The default regex used to naively identify numeric versions from tags
# when a regex isn't provided.
DEFAULT_REGEX = /\D*(.+)/.freeze
# Whether the strategy can be applied to the provided URL.
#
# @param url [String] the URL to match against
# @return [Boolean]
sig { params(url: String).returns(T::Boolean) }
def self.match?(url)
(DownloadStrategyDetector.detect(url) <= GitDownloadStrategy) == true
end
# Fetches a remote Git repository's tags using `git ls-remote --tags`
# and parses the command's output. If a regex is provided, it will be
# used to filter out any tags that don't match it.
#
# @param url [String] the URL of the Git repository to check
# @param regex [Regexp] the regex to use for filtering tags
# @return [Hash]
def self.tag_info(url, regex = nil)
# Open3#capture3 is used here because we need to capture stderr
# output and handle it in an appropriate manner. Alternatives like
# SystemCommand always print errors (as well as debug output) and
# don't meet the same goals.
stdout_str, stderr_str, _status = Open3.capture3(
{ "GIT_TERMINAL_PROMPT" => "0" }, "git", "ls-remote", "--tags", url
)
tags_data = { tags: [] }
tags_data[:messages] = stderr_str.split("\n") if stderr_str.present?
return tags_data if stdout_str.blank?
# Isolate tag strings by removing leading/trailing text
stdout_str.gsub!(%r{^.*\trefs/tags/}, "")
stdout_str.gsub!("^{}", "")
tags = stdout_str.split("\n").uniq.sort
tags.select! { |t| t =~ regex } if regex
tags_data[:tags] = tags
tags_data
end
# Identify versions from tag strings using a provided regex or the
# `DEFAULT_REGEX`. The regex is expected to use a capture group around
# the version text.
#
# @param tags [Array] the tags to identify versions from
# @param regex [Regexp, nil] a regex to identify versions
# @return [Array]
sig {
params(
tags: T::Array[String],
regex: T.nilable(Regexp),
block: T.nilable(
T.proc.params(arg0: T::Array[String], arg1: T.nilable(Regexp))
.returns(T.any(String, T::Array[String], NilClass)),
),
).returns(T::Array[String])
}
def self.versions_from_tags(tags, regex = nil, &block)
return Strategy.handle_block_return(block.call(tags, regex || DEFAULT_REGEX)) if block
tags_only_debian = tags.all? { |tag| tag.start_with?("debian/") }
tags.map do |tag|
# Skip tag if it has a 'debian/' prefix and upstream does not do
# only 'debian/' prefixed tags
next if tag =~ %r{^debian/} && !tags_only_debian
if regex
# Use the first capture group (the version)
tag.scan(regex).first&.first
else
# Remove non-digits from the start of the tag and use that as the
# version text
tag[DEFAULT_REGEX, 1]
end
end.compact.uniq
end
# Checks the Git tags for new versions. When a regex isn't provided,
# this strategy simply removes non-digits from the start of tag
# strings and parses the remaining text as a {Version}.
#
# @param url [String] the URL of the Git repository to check
# @param regex [Regexp] the regex to use for matching versions
# @return [Hash]
sig {
params(
url: String,
regex: T.nilable(Regexp),
cask: T.nilable(Cask::Cask),
block: T.nilable(
T.proc.params(arg0: T::Array[String], arg1: T.nilable(Regexp))
.returns(T.any(String, T::Array[String], NilClass)),
),
).returns(T::Hash[Symbol, T.untyped])
}
def self.find_versions(url, regex = nil, cask: nil, &block)
match_data = { matches: {}, regex: regex, url: url }
tags_data = tag_info(url, regex)
tags = tags_data[:tags]
if tags_data.key?(:messages)
match_data[:messages] = tags_data[:messages]
return match_data if tags.blank?
end
versions_from_tags(tags, regex, &block).each do |version_text|
match_data[:matches][version_text] = Version.new(version_text)
rescue TypeError
next
end
match_data
end
end
end
end
end