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
+