diff --git a/gradle/lib/dependabot/gradle/package/package_details_fetcher.rb b/gradle/lib/dependabot/gradle/package/package_details_fetcher.rb index c0fd554e5f2..a12dcc051cb 100644 --- a/gradle/lib/dependabot/gradle/package/package_details_fetcher.rb +++ b/gradle/lib/dependabot/gradle/package/package_details_fetcher.rb @@ -12,6 +12,7 @@ require "dependabot/maven/utils/auth_headers_finder" require "sorbet-runtime" require "dependabot/gradle/metadata_finder" +require "dependabot/gradle/package/release_date_extractor" module Dependabot module Gradle @@ -101,37 +102,16 @@ def fetch_available_versions sig { returns(T::Hash[String, T::Hash[Symbol, T.untyped]]) } def release_details - release_date_info = T.let({}, T::Hash[String, T::Hash[Symbol, T.untyped]]) - - begin - repositories.map do |repository_details| - url = repository_details.fetch("url") - next unless url == Gradle::FileParser::RepositoriesFinder::CENTRAL_REPO_URL - - release_info_metadata(repository_details).css("a[title]").each do |link| - version_string = link["title"] - version = version_string.gsub(%r{/$}, "") - raw_date_text = link.next.text.strip.split("\n").last.strip - - release_date = begin - Time.parse(raw_date_text) - rescue StandardError - nil - end - - next unless version && version_class.correct?(version) - - release_date_info[version] = { - release_date: release_date - } - end - end + extractor = ReleaseDateExtractor.new( + dependency_name: dependency.name, + version_class: version_class + ) - release_date_info - rescue StandardError - Dependabot.logger.error("Failed to get release date") - {} - end + extractor.extract( + repositories: repositories, + dependency_metadata_fetcher: ->(repo) { dependency_metadata(repo) }, + release_info_metadata_fetcher: ->(repo) { release_info_metadata(repo) } + ) end sig { returns(T::Array[T::Hash[String, T.untyped]]) } @@ -226,6 +206,8 @@ def dependency_metadata(repository_details) end end + # Fetches HTML directory listing from Maven-compatible repositories. + # Uses CSS selector "a[title]" to extract versions and dates. Caches results per repository. sig { params(repository_details: T::Hash[T.untyped, T.untyped]).returns(T.untyped) } def release_info_metadata(repository_details) @release_info_metadata ||= T.let({}, T.nilable(T::Hash[Integer, T.untyped])) @@ -237,14 +219,14 @@ def release_info_metadata(repository_details) ) check_response(response, repository_details.fetch("url")) - Nokogiri::XML(response.body) + Nokogiri::HTML(response.body) rescue URI::InvalidURIError - Nokogiri::XML("") + Nokogiri::HTML("") rescue Excon::Error::Socket, Excon::Error::Timeout, Excon::Error::TooManyRedirects raise if central_repo_urls.include?(repository_details["url"]) - Nokogiri::XML("") + Nokogiri::HTML("") end end diff --git a/gradle/lib/dependabot/gradle/package/release_date_extractor.rb b/gradle/lib/dependabot/gradle/package/release_date_extractor.rb new file mode 100644 index 00000000000..d4502acec02 --- /dev/null +++ b/gradle/lib/dependabot/gradle/package/release_date_extractor.rb @@ -0,0 +1,166 @@ +# typed: strict +# frozen_string_literal: true + +require "nokogiri" +require "time" +require "sorbet-runtime" +require "dependabot/logger" + +module Dependabot + module Gradle + module Package + # Extracts release dates from repository metadata to support the cooldown feature. + # Handles multiple repository formats (Maven Central HTML listings, Gradle Plugin Portal XML). + class ReleaseDateExtractor + extend T::Sig + + sig do + params( + dependency_name: String, + version_class: T.class_of(Dependabot::Version) + ).void + end + def initialize(dependency_name:, version_class:) + @dependency_name = dependency_name + @version_class = version_class + end + + # Extracts release dates from all repositories. + # Attempts both parsing strategies for all repositories: + # 1. Gradle Plugin Portal style: maven-metadata.xml with lastUpdated timestamp (latest version only) + # 2. Maven repository style: HTML directory listings with per-version dates + # This supports mirrors/proxies of both Maven Central and Gradle Plugin Portal. + sig do + params( + repositories: T::Array[T::Hash[String, T.untyped]], + dependency_metadata_fetcher: T.proc.params( + repo: T::Hash[String, T.untyped] + ).returns(Nokogiri::XML::Document), + release_info_metadata_fetcher: T.proc.params( + repo: T::Hash[String, T.untyped] + ).returns(Nokogiri::HTML::Document) + ).returns(T::Hash[String, T::Hash[Symbol, T.untyped]]) + end + def extract(repositories:, dependency_metadata_fetcher:, release_info_metadata_fetcher:) + release_date_info = T.let({}, T::Hash[String, T::Hash[Symbol, T.untyped]]) + + begin + repositories.each do |repository_details| + parse_gradle_plugin_portal_release( + repository_details, + release_date_info, + dependency_metadata_fetcher + ) + + parse_maven_central_releases( + repository_details, + release_date_info, + release_info_metadata_fetcher + ) + end + + release_date_info + rescue StandardError => e + Dependabot.logger.error( + "Failed to get release date for #{@dependency_name}: #{e.class} - #{e.message}" + ) + Dependabot.logger.error(e.backtrace&.join("\n") || "No backtrace available") + {} + end + end + + private + + sig { returns(String) } + attr_reader :dependency_name + + sig { returns(T.class_of(Dependabot::Version)) } + attr_reader :version_class + + # Parses Maven-style HTML directory listings to extract release dates. + sig do + params( + repository_details: T::Hash[String, T.untyped], + release_date_info: T::Hash[String, T::Hash[Symbol, T.untyped]], + metadata_fetcher: T.proc.params( + repo: T::Hash[String, T.untyped] + ).returns(Nokogiri::HTML::Document) + ).void + end + def parse_maven_central_releases(repository_details, release_date_info, metadata_fetcher) + metadata_fetcher.call(repository_details).css("a[title]").each do |link| + title = link["title"] + next unless title + + version = title.gsub(%r{/$}, "") + next unless version_class.correct?(version) + next if release_date_info.key?(version) + + release_date = extract_release_date_from_link(link, version) + release_date_info[version] = { release_date: release_date } + end + rescue StandardError => e + Dependabot.logger.debug( + "Could not parse Maven-style release dates from #{repository_details.fetch('url')} " \ + "for #{dependency_name}: #{e.message}" + ) + end + + # Parses Gradle Plugin Portal maven-metadata.xml for release dates. + sig do + params( + repository_details: T::Hash[String, T.untyped], + release_date_info: T::Hash[String, T::Hash[Symbol, T.untyped]], + metadata_fetcher: T.proc.params( + repo: T::Hash[String, T.untyped] + ).returns(Nokogiri::XML::Document) + ).void + end + def parse_gradle_plugin_portal_release(repository_details, release_date_info, metadata_fetcher) + metadata_xml = metadata_fetcher.call(repository_details) + last_updated = metadata_xml.at_xpath("//metadata/versioning/lastUpdated")&.text&.strip + latest_version = metadata_xml.at_xpath("//metadata/versioning/latest")&.text&.strip + + return unless latest_version && version_class.correct?(latest_version) + return if release_date_info.key?(latest_version) + + release_date = parse_gradle_timestamp(last_updated) + Dependabot.logger.info( + "Parsed Gradle Plugin Portal release for #{dependency_name}: #{latest_version} at #{release_date}" + ) + release_date_info[latest_version] = { release_date: release_date } + rescue StandardError => e + Dependabot.logger.debug( + "Could not parse Gradle Plugin Portal metadata from #{repository_details.fetch('url')} " \ + "for #{dependency_name}: #{e.message}" + ) + end + + # Extracts release date from HTML link element's adjacent text. + sig { params(link: Nokogiri::XML::Element, version: String).returns(T.nilable(Time)) } + def extract_release_date_from_link(link, version) + raw_date_text = link.next.text.strip.split("\n").last.strip + Time.parse(raw_date_text) + rescue StandardError => e + Dependabot.logger.debug( + "Failed to parse release date for #{dependency_name} version #{version}: #{e.message}" + ) + nil + end + + # Parses Gradle Plugin Portal timestamp format (YYYYMMDDHHmmss). + sig { params(timestamp: T.nilable(String)).returns(T.nilable(Time)) } + def parse_gradle_timestamp(timestamp) + return nil if timestamp.nil? || timestamp.empty? + + Time.strptime(timestamp, "%Y%m%d%H%M%S") + rescue ArgumentError => e + Dependabot.logger.warn( + "Failed to parse Gradle timestamp for #{dependency_name}: '#{timestamp}' - #{e.message}" + ) + nil + end + end + end + end +end diff --git a/gradle/spec/dependabot/gradle/package/package_details_fetcher_spec.rb b/gradle/spec/dependabot/gradle/package/package_details_fetcher_spec.rb index 83d11a14d5c..cf723c45f01 100644 --- a/gradle/spec/dependabot/gradle/package/package_details_fetcher_spec.rb +++ b/gradle/spec/dependabot/gradle/package/package_details_fetcher_spec.rb @@ -106,11 +106,24 @@ "https://repo.maven.apache.org/maven2/org/springframework/boot/" \ "org.springframework.boot.gradle.plugin/maven-metadata.xml" end + let(:maven_central_html_url) do + "https://repo.maven.apache.org/maven2/org/springframework/boot/" \ + "org.springframework.boot.gradle.plugin/" + end before do stub_request(:get, gradle_plugin_metadata_url) .to_return(status: 200, body: gradle_plugin_releases) stub_request(:get, maven_metadata_url).to_return(status: 404) + # Stub the HTML directory listing request for Maven Central + stub_request(:get, maven_central_html_url).to_return(status: 404) + end + + it "populates release_details for the latest version" do + release_info = packagedetailsfetcher.send(:release_details) + expect(release_info).to be_a(Hash) + expect(release_info).to have_key("2.1.4.RELEASE") + expect(release_info["2.1.4.RELEASE"][:release_date]).to eq(Time.utc(2019, 4, 4, 5, 30, 33)) end describe "the first version" do @@ -135,6 +148,11 @@ its([:source_url]) do is_expected.to eq("https://plugins.gradle.org/m2") end + + its([:released_at]) do + # lastUpdated from fixture: 20190404053033 (2019-04-04 05:30:33 UTC) + is_expected.to eq(Time.utc(2019, 4, 4, 5, 30, 33)) + end end end @@ -161,11 +179,17 @@ "https://repo.maven.apache.org/maven2/org/jetbrains/kotlin/jvm/" \ "org.jetbrains.kotlin.jvm.gradle.plugin/maven-metadata.xml" end + let(:maven_central_html_url) do + "https://repo.maven.apache.org/maven2/org/jetbrains/kotlin/jvm/" \ + "org.jetbrains.kotlin.jvm.gradle.plugin/" + end before do stub_request(:get, gradle_plugin_metadata_url) .to_return(status: 200, body: gradle_plugin_releases) stub_request(:get, maven_metadata_url).to_return(status: 404) + # Stub the HTML directory listing request for Maven Central + stub_request(:get, maven_central_html_url).to_return(status: 404) end describe "the first version" do @@ -190,6 +214,11 @@ its([:source_url]) do is_expected.to eq("https://plugins.gradle.org/m2") end + + its([:released_at]) do + # lastUpdated from fixture: 20201222143435 (2020-12-22 14:34:35 UTC) + is_expected.to eq(Time.utc(2020, 12, 22, 14, 34, 35)) + end end end diff --git a/gradle/spec/dependabot/gradle/package/release_date_extractor_spec.rb b/gradle/spec/dependabot/gradle/package/release_date_extractor_spec.rb new file mode 100644 index 00000000000..f5f45574cd8 --- /dev/null +++ b/gradle/spec/dependabot/gradle/package/release_date_extractor_spec.rb @@ -0,0 +1,132 @@ +# typed: false +# frozen_string_literal: true + +require "spec_helper" +require "dependabot/gradle/package/release_date_extractor" + +RSpec.describe Dependabot::Gradle::Package::ReleaseDateExtractor do + let(:extractor) do + described_class.new( + dependency_name: dependency_name, + version_class: version_class + ) + end + let(:dependency_name) { "com.example:test-library" } + let(:version_class) { Dependabot::Gradle::Version } + + describe "#extract" do + subject(:extract_release_dates) do + extractor.extract( + repositories: repositories, + dependency_metadata_fetcher: dependency_metadata_fetcher, + release_info_metadata_fetcher: release_info_metadata_fetcher + ) + end + + let(:repositories) { [] } + let(:dependency_metadata_fetcher) { ->(_repo) { Nokogiri::XML("") } } + let(:release_info_metadata_fetcher) { ->(_repo) { Nokogiri::HTML("") } } + + context "with empty repositories" do + it "returns empty hash" do + expect(extract_release_dates).to eq({}) + end + end + + context "with Gradle Plugin Portal style repository" do + let(:repositories) do + [{ "url" => "https://plugins.gradle.org/m2", "auth_headers" => {} }] + end + let(:maven_metadata_xml) do + <<~XML + + + 1.2.0 + 20191201191459 + + + XML + end + let(:dependency_metadata_fetcher) { ->(_repo) { Nokogiri::XML(maven_metadata_xml) } } + + it "extracts release date from lastUpdated timestamp" do + result = extract_release_dates + expect(result["1.2.0"]).to eq({ release_date: Time.utc(2019, 12, 1, 19, 14, 59) }) + end + end + + context "with Maven Central style repository" do + let(:repositories) do + [{ "url" => "https://repo.maven.apache.org/maven2", "auth_headers" => {} }] + end + let(:html_listing) do + <<~HTML + + + 2019-11-01 10:00 - + 2019-12-01 14:30 - + + + HTML + end + let(:release_info_metadata_fetcher) { ->(_repo) { Nokogiri::HTML(html_listing) } } + + it "extracts release dates from HTML directory listing" do + result = extract_release_dates + expect(result["1.0.0"][:release_date]).to be_a(Time) + expect(result["1.1.0"][:release_date]).to be_a(Time) + end + end + + context "with both repository styles" do + let(:repositories) do + [ + { "url" => "https://plugins.gradle.org/m2", "auth_headers" => {} }, + { "url" => "https://repo.maven.apache.org/maven2", "auth_headers" => {} } + ] + end + let(:maven_metadata_xml) do + <<~XML + + + 1.2.0 + 20191201191459 + + + XML + end + let(:html_listing) do + <<~HTML + + + 2019-11-01 10:00 - + 2019-12-01 14:30 - + + + HTML + end + let(:dependency_metadata_fetcher) { ->(_repo) { Nokogiri::XML(maven_metadata_xml) } } + let(:release_info_metadata_fetcher) { ->(_repo) { Nokogiri::HTML(html_listing) } } + + it "combines data from both sources without duplicates" do + result = extract_release_dates + # Version 1.2.0 should be found from Gradle Plugin Portal first, not overwritten by Maven + expect(result["1.2.0"]).to eq({ release_date: Time.utc(2019, 12, 1, 19, 14, 59) }) + expect(result["1.0.0"][:release_date]).to be_a(Time) + end + end + + context "when parsing fails" do + let(:repositories) do + [{ "url" => "https://example.com", "auth_headers" => {} }] + end + let(:dependency_metadata_fetcher) do + ->(_repo) { raise StandardError, "Network error" } + end + + it "returns empty hash on failure" do + expect(extract_release_dates).to eq({}) + end + end + end +end