Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add semver 2 versioning in dependabot common #10434

Merged
merged 2 commits into from
Aug 20, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 134 additions & 0 deletions common/lib/dependabot/sem_version.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
# typed: strong
# frozen_string_literal: true

require "sorbet-runtime"

# See https://semver.org/spec/v2.0.0.html for semver 2 details
#
module Dependabot
class SemVersion
amazimbe marked this conversation as resolved.
Show resolved Hide resolved
extend T::Sig
extend T::Helpers
include Comparable

SEMVER_REGEX = /^
(0|[1-9]\d*)\. # major
(0|[1-9]\d*)\. # minor
(0|[1-9]\d*) # patch
(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))? # pre release
(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))? # build metadata
$/x

sig { returns(String) }
attr_accessor :major

sig { returns(String) }
attr_accessor :minor

sig { returns(String) }
attr_accessor :patch

sig { returns(T.nilable(String)) }
attr_accessor :build

sig { returns(T.nilable(String)) }
attr_accessor :prerelease

sig { params(version: String).void }
def initialize(version)
tokens = parse(version)
@major = T.let(T.must(tokens[:major]), String)
@minor = T.let(T.must(tokens[:minor]), String)
@patch = T.let(T.must(tokens[:patch]), String)
@build = T.let(tokens[:build], T.nilable(String))
@prerelease = T.let(tokens[:prerelease], T.nilable(String))
end

sig { returns(T::Boolean) }
def prerelease?
!!prerelease
end

sig { returns(String) }
def to_s
value = [major, minor, patch].join(".")
value += "-#{prerelease}" if prerelease
value += "+#{build}" if build
value
end

sig { returns(String) }
def inspect
"#<#{self.class} #{self}>"
end

sig { params(other: ::Dependabot::SemVersion).returns(T::Boolean) }
def eql?(other)
other.is_a?(self.class) && to_s == other.to_s
end

sig { params(other: ::Dependabot::SemVersion).returns(Integer) }
def <=>(other)
maj = major.to_i <=> other.major.to_i
amazimbe marked this conversation as resolved.
Show resolved Hide resolved
return maj unless maj.zero?

min = minor.to_i <=> other.minor.to_i
return min unless min.zero?

pat = patch.to_i <=> other.patch.to_i
return pat unless pat.zero?

pre = compare_prereleases(prerelease, other.prerelease)
return pre unless pre.zero?

0
end

sig { params(version: T.nilable(String)).returns(T::Boolean) }
def self.correct?(version)
return false if version.nil?

version.match?(SEMVER_REGEX)
end

private

sig { params(version: String).returns(T::Hash[Symbol, T.nilable(String)]) }
def parse(version)
match = version.match(SEMVER_REGEX)
raise ArgumentError, "Malformed version number string #{version}" unless match

major, minor, patch, prerelease, build = match.captures
raise ArgumentError, "Malformed version number string #{version}" if minor.empty? || patch.empty?

{ major: major, minor: minor, patch: patch, prerelease: prerelease, build: build }
end

sig { params(prerelease1: T.nilable(String), prerelease2: T.nilable(String)).returns(Integer) }
def compare_prereleases(prerelease1, prerelease2) # rubocop:disable Metrics/PerceivedComplexity
return 0 if prerelease1.nil? && prerelease2.nil?
return -1 if prerelease2.nil?
return 1 if prerelease1.nil?

prerelease1_tokens = prerelease1.split(".")
prerelease2_tokens = prerelease2.split(".")

prerelease1_tokens.zip(prerelease2_tokens) do |t1, t2|
return 1 if t2.nil? # t2 can be nil, in which case it loses

# If they're both ints, convert to such
# If one's an int and the other isn't, the string version of the int gets correctly compared
if t1 =~ /^\d+$/ && t2 =~ /^\d+$/
t1 = t1.to_i
t2 = t2.to_i
end

comp = t1 <=> t2
return comp unless comp.zero?
end

# If we got this far, either they're equal (same length) or they won
prerelease1_tokens.length == prerelease2_tokens.length ? 0 : -1
end
end
end
190 changes: 190 additions & 0 deletions common/spec/dependabot/sem_version_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
# typed: true
# frozen_string_literal: true

require "spec_helper"
require "dependabot/sem_version"

RSpec.describe Dependabot::SemVersion do
subject(:version) { described_class.new(version_string) }

let(:valid_versions) do
%w( 0.0.4 1.2.3 10.20.30 1.1.2-prerelease+meta 1.1.2+meta 1.1.2+meta-valid 1.0.0-alpha
1.0.0-beta 1.0.0-alpha.beta 1.0.0-alpha.beta.1 1.0.0-alpha.1 1.0.0-alpha0.valid
1.0.0-alpha.0valid 1.0.0-alpha-a.b-c-somethinglong+build.1-aef.1-its-okay
1.0.0-rc.1+build.1 2.0.0-rc.1+build.123 1.2.3-beta 10.2.3-DEV-SNAPSHOT
1.2.3-SNAPSHOT-123 2.0.0+build.1848 2.0.1-alpha.1227 1.0.0-alpha+beta
1.2.3----RC-SNAPSHOT.12.9.1--.12+788 1.2.3----R-S.12.9.1--.12+meta
1.2.3----RC-SNAPSHOT.12.9.1--.12 1.0.0+0.build.1-rc.10000aaa-kk-0.1
9999999.999999999.99999999 1.0.0-0A.is.legal)
end

let(:invalid_versions) do
%w(1 1.2 1.2.3-0123 1.2.3-0123.0123 1.1.2+.123 +invalid -invalid -invalid+invalid -invalid.01 alpha alpha.beta
alpha.beta.1 alpha.1 alpha+beta alpha_beta alpha. alpha.. beta 1.0.0-alpha_beta -alpha. 1.0.0-alpha..
1.0.0-alpha..1 1.0.0-alpha...1 1.0.0-alpha....1 1.0.0-alpha.....1 1.0.0-alpha......1 1.0.0-alpha.......1
01.1.1 1.01.1 1.1.01 1.2.3.DEV 1.2-SNAPSHOT 1.2.31.2.3----RC-SNAPSHOT.12.09.1--..12+788 1.2-RC-SNAPSHOT
-1.0.3-gamma+b7718 +justmeta 9.8.7+meta+meta 9.8.7-whatever+meta+meta
999.9999.99999----RC-SNAPSHOT.12.09.1----..12)
end

describe "#initialize" do
context "when the version is invalid" do
let(:version_string) { "1" }
let(:error_message) { "Malformed version number string #{version_string}" }

it "raises an error" do
expect { version }.to raise_error(ArgumentError, error_message)
end
end
end

describe "to_s" do
it "returns the correct value" do
valid_versions.each do |version|
expect(described_class.new(version).to_s).to eq(version)
end
end
end

describe "#inspect" do
subject { described_class.new(version_string).inspect }

let(:version_string) { "1.0.0+build1" }

it { is_expected.to eq("#<#{described_class} #{version_string}>") }
end

describe "#eql?" do
let(:first) { described_class.new("1.2.3-rc.1+build1") }
let(:second) { described_class.new("1.2.3-rc.1+build1") }

it "returns true for equal semver values" do
expect(first).to eql(second)
end
end

describe "#<=>" do
it "sorts version strings semantically" do
versions = []

versions << described_class.new("1.0.0-alpha")
versions << described_class.new("1.0.0-alpha.1")
versions << described_class.new("1.0.0-alpha.1.beta.gamma")
versions << described_class.new("1.0.0-alpha.beta")
versions << described_class.new("1.0.0-alpha.beta.1")
versions << described_class.new("1.0.0-beta")
versions << described_class.new("1.0.0-beta.2")
versions << described_class.new("1.0.0-beta.11")
versions << described_class.new("1.0.0-rc.1")
versions << described_class.new("1.0.0")
expect(versions.shuffle.sort).to eq(versions)
abdulapopoola marked this conversation as resolved.
Show resolved Hide resolved
end

context "when comparing numerical prereleases" do
let(:first) { described_class.new("1.0.0-rc.2") }
let(:second) { described_class.new("1.0.0-rc.10") }

it "compares numerically" do
expect(first <=> second).to eq(-1)
expect(second <=> first).to eq(1)
end
end

context "when comparing alphanumerical prereleases" do
let(:first) { described_class.new("1.0.0-alpha10") }
let(:second) { described_class.new("1.0.0-alpha2") }

it "compares lexicographically" do
expect(first <=> second).to eq(-1)
expect(second <=> first).to eq(1)
end
end

context "when comparing versions that contain build data" do
let(:first) { described_class.new("1.0.0+build-123") }
let(:second) { described_class.new("1.0.0+build-456") }

it "ignores build metadata" do
expect(first <=> second).to eq(0)
end
end
end

describe "#prerelease?" do
subject { version.prerelease? }

context "with an alpha" do
let(:version_string) { "1.0.0-alpha" }

it { is_expected.to be(true) }
end

context "with a capitalised alpha" do
let(:version_string) { "1.0.0-Alpha" }

it { is_expected.to be(true) }
end

context "with a dev token" do
let(:version_string) { "1.2.1-dev-65" }

it { is_expected.to be(true) }
end

context "with a 'pre' pre-release separated with a -" do
let(:version_string) { "2.10.0-pre0" }

it { is_expected.to be(true) }
end

context "with a release" do
let(:version_string) { "1.0.0" }

it { is_expected.to be(false) }
end

context "with a + separated alphanumeric build identifier" do
let(:version_string) { "1.0.0+build1" }

it { is_expected.to be(false) }
end

context "with an 'alpha' separated by a -" do
let(:version_string) { "1.0.0-alpha+001" }

it { is_expected.to be(true) }
end
end

describe ".correct?" do
subject { described_class.correct?(version_string) }

context "with a nil version" do
let(:version_string) { nil }

it { is_expected.to be(false) }
end

context "with an empty version" do
let(:version_string) { "" }

it { is_expected.to be(false) }
end

context "with valid semver2 strings" do
it "returns true" do
valid_versions.each do |version|
expect(described_class.correct?(version)).to be(true)
end
end
end

context "with invalid semver2 strings" do
it "returns false" do
invalid_versions.each do |version|
expect(described_class.correct?(version)).to be(false)
end
end
end
end
end
Loading