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 Metrics Collection for Composer Ecosystem: Package Manager and Language Details #11025

Merged
merged 11 commits into from
Dec 3, 2024
63 changes: 58 additions & 5 deletions composer/lib/dependabot/composer/file_parser.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@

module Dependabot
module Composer
REQUIREMENT_SEPARATOR = /
(?<=\S|^) # Positive lookbehind for a non-whitespace character or start of string
(?:[ \t,]*\|\|?[ \t]*) # Match optional whitespace, a pipe (|| or |), and optional whitespace
(?=\S|$) # Positive lookahead for a non-whitespace character or end of string
/x

class FileParser < Dependabot::FileParsers::Base
require "dependabot/file_parsers/base/dependency_set"

Expand Down Expand Up @@ -40,7 +46,8 @@ def ecosystem
@ecosystem ||= T.let(
Ecosystem.new(
name: ECOSYSTEM,
package_manager: package_manager
package_manager: package_manager,
language: language
),
T.nilable(Ecosystem)
)
Expand All @@ -50,7 +57,48 @@ def ecosystem

sig { returns(Ecosystem::VersionManager) }
def package_manager
PackageManager.new(composer_version)
raw_composer_version = env_versions[:composer] || composer_version
PackageManager.new(
raw_composer_version
)
end

sig { returns(T.nilable(Ecosystem::VersionManager)) }
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is it possible to return N/A in case this is also not defined instead of nil?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a instance object, It will be hard to return N/A in case if we don't have version. If we want to do that we need to return T.any(String, Ecosystem::VersionManager), 2 different types which will make it more complicated.

def language
php_version = env_versions[:php]

return unless php_version

Language.new(
php_version,
requirement: php_requirement
)
end

sig { returns(T::Hash[Symbol, T.nilable(String)]) }
def env_versions
@env_versions ||= T.let(
Helpers.fetch_composer_and_php_versions,
T.nilable(T::Hash[Symbol, T.nilable(String)])
)
end

# Capture PHP requirement from the composer.json
sig { returns(T.nilable(Requirement)) }
def php_requirement
requirement_string = Helpers.php_constraint(parsed_composer_json)

return nil unless requirement_string

requirements = requirement_string
.strip
.split(REQUIREMENT_SEPARATOR)
.map(&:strip)
.reject(&:empty?)

return nil unless requirements.any?

Requirement.new(requirements)
end

sig { returns(DependencySet) }
Expand Down Expand Up @@ -162,7 +210,8 @@ def dependency_version(name:, type:)
end

sig do
params(name: String, type: String, requirement: String).returns(T.nilable(T::Hash[Symbol, T.nilable(String)]))
params(name: String, type: String,
requirement: String).returns(T.nilable(T::Hash[Symbol, T.nilable(String)]))
end
def dependency_source(name:, type:, requirement:)
return unless lockfile
Expand Down Expand Up @@ -243,7 +292,10 @@ def parsed_lockfile # rubocop:disable Metrics/PerceivedComplexity
def parsed_composer_json
content = composer_json&.content

raise Dependabot::DependencyFileNotParseable, composer_json&.path || "" if content.nil? || content.strip.empty?
if content.nil? || content.strip.empty?
raise Dependabot::DependencyFileNotParseable,
composer_json&.path || ""
end

@parsed_composer_json ||= T.let(JSON.parse(content), T.nilable(T::Hash[String, T.untyped]))
rescue JSON::ParserError
Expand All @@ -268,7 +320,8 @@ def lockfile

sig { returns(String) }
def composer_version
@composer_version ||= T.let(Helpers.composer_version(parsed_composer_json, parsed_lockfile), T.nilable(String))
@composer_version ||= T.let(Helpers.composer_version(parsed_composer_json, parsed_lockfile),
T.nilable(String))
end
end
end
Expand Down
49 changes: 49 additions & 0 deletions composer/lib/dependabot/composer/helpers.rb
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,55 @@ def self.extract_and_clean_dependency_url(message, regex)
nil
end

# Run single composer command returning stdout/stderr
sig { params(command: String, fingerprint: T.nilable(String)).returns(String) }
def self.package_manager_run_command(command, fingerprint: nil)
full_command = "composer #{command}"

Dependabot.logger.info("Running composer command: #{full_command}")

result = Dependabot::SharedHelpers.run_shell_command(
full_command,
fingerprint: "composer #{fingerprint || command}"
).strip

Dependabot.logger.info("Command executed successfully: #{full_command}")
result
rescue StandardError => e
Dependabot.logger.error("Error running composer command: #{full_command}, Error: #{e.message}")
raise
end

# Example output:
# [dependabot] ~ $ composer --version
# Composer version 2.7.7 2024-06-10 22:11:12
# PHP version 7.4.33 (/usr/bin/php7.4)
# Run the "diagnose" command to get more detailed diagnostics output.
# Get the version of the composer and php form the command output
# @return [Hash] with the composer and php version
# => { composer: "2.7.7", php: "7.4.33" }
sig { returns(T::Hash[Symbol, T.nilable(String)]) }
def self.fetch_composer_and_php_versions
output = package_manager_run_command("--version").strip

composer_version = capture_version(output, /Composer version (?<version>\d+\.\d+\.\d+)/)
php_version = capture_version(output, /PHP version (?<version>\d+\.\d+\.\d+)/)

Dependabot.logger.info("Dependabot running with Composer version: #{composer_version}")
Dependabot.logger.info("Dependabot running with PHP version: #{php_version}")

{ composer: composer_version, php: php_version }
rescue StandardError => e
Dependabot.logger.error("Error fetching versions for package manager and language #{name}: #{e.message}")
{}
end

sig { params(output: String, regex: Regexp).returns(T.nilable(String)) }
def self.capture_version(output, regex)
match = output.match(regex)
match&.named_captures&.fetch("version", nil)
end

# Capture the platform PHP version from composer.json
sig { params(parsed_composer_json: T::Hash[String, T.untyped]).returns(T.nilable(String)) }
def self.capture_platform_php(parsed_composer_json)
Expand Down
8 changes: 6 additions & 2 deletions composer/lib/dependabot/composer/language.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

require "sorbet-runtime"
require "dependabot/ecosystem"
require "dependabot/composer/requirement"
require "dependabot/composer/version"

module Dependabot
Expand All @@ -12,11 +13,14 @@ class Language < Dependabot::Ecosystem::VersionManager

NAME = "php"

sig { params(raw_version: String).void }
def initialize(raw_version)
sig { params(raw_version: String, requirement: T.nilable(Requirement)).void }
def initialize(raw_version, requirement: nil)
super(
NAME,
Version.new(raw_version),
[],
[],
requirement
)
end

Expand Down
33 changes: 31 additions & 2 deletions composer/spec/dependabot/composer/file_parser_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -410,9 +410,38 @@
end
end

describe "#package_manager" do
it "returns the correct package manager" do
describe "#ecosystem" do
it "returns the correct ecosystem" do
expect(parser.ecosystem).to be_a(Dependabot::Ecosystem)
end

it "returns package manager with version" do
expect(parser.ecosystem.package_manager).to be_a(Dependabot::Composer::PackageManager)
expect(parser.ecosystem.package_manager.version).to eq("2.7.7")
end

it "returns language with version" do
expect(parser.ecosystem.language).to be_a(Dependabot::Composer::Language)
expect(parser.ecosystem.language.version).to eq("7.4.33")
end
end

describe "REQUIREMENT_SEPARATOR" do
let(:requirements) do
[
"php >=7.4 || php >=8.0",
"php >=7.4, || php >=8.0",
"php >=7.4 |||| php >=8.0"
]
end

it "splits requirements correctly" do
results = requirements.map { |req| req.split(Dependabot::Composer::REQUIREMENT_SEPARATOR) }
expect(results).to eq([
["php >=7.4", "php >=8.0"],
["php >=7.4", "php >=8.0"],
["php >=7.4", "", "php >=8.0"]
])
end
end
end
147 changes: 147 additions & 0 deletions composer/spec/dependabot/composer/helpers_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
# frozen_string_literal: true

require "spec_helper"
require "dependabot/composer/package_manager"
require "dependabot/composer/language"
require "dependabot/composer/helpers"

RSpec.describe Dependabot::Composer::Helpers do
Expand Down Expand Up @@ -57,4 +59,149 @@
expect(described_class.composer_version(composer_json)).to eq("2")
end
end

describe ".package_manager_run_command" do
let(:command) { "--version" }
let(:fingerprint) { nil }
let(:output) { "Composer version 2.7.7\nPHP version 7.4.33" }

before do
allow(Dependabot::SharedHelpers).to receive(:run_shell_command).and_return(output)
end

it "runs the given composer command and returns the output" do
expect(described_class.package_manager_run_command(command)).to eq(output.strip)
end

it "logs the command execution and success" do
expect(Dependabot.logger).to receive(:info).with("Running composer command: composer --version")
expect(Dependabot.logger).to receive(:info).with("Command executed successfully: composer --version")
described_class.package_manager_run_command(command)
end

it "logs and raises an error if the command fails" do
allow(Dependabot::SharedHelpers).to receive(:run_shell_command).and_raise(StandardError, "Command failed")
expect(Dependabot.logger).to receive(:error).with(/Error running composer command/)
expect { described_class.package_manager_run_command(command) }.to raise_error(StandardError, "Command failed")
end
end

describe ".fetch_composer_and_php_versions" do
let(:output) do
"Composer version 2.7.7 2024-06-10 22:11:12\nPHP version 7.4.33 (/usr/bin/php7.4)"
end

before do
allow(described_class).to receive(:package_manager_run_command).and_return(output)
end

it "fetches and parses composer and PHP versions correctly" do
expect(described_class.fetch_composer_and_php_versions).to eq({
composer: "2.7.7",
php: "7.4.33"
})
end

it "logs the composer and PHP versions" do
expect(Dependabot.logger).to receive(:info).with(/Dependabot running with Composer version/)
expect(Dependabot.logger).to receive(:info).with(/Dependabot running with PHP version/)
described_class.fetch_composer_and_php_versions
end

it "logs and returns an empty hash on failure" do
allow(described_class).to receive(:package_manager_run_command).and_raise(StandardError, "Command failed")
expect(Dependabot.logger).to receive(:error).with(/Error fetching versions/)
expect(described_class.fetch_composer_and_php_versions).to eq({})
end
end

describe ".capture_version" do
let(:output) { "Composer version 2.7.7\nPHP version 7.4.33" }

it "captures the version from the output using the provided regex" do
expect(described_class.capture_version(output, /Composer version (?<version>\d+\.\d+\.\d+)/)).to eq("2.7.7")
expect(described_class.capture_version(output, /PHP version (?<version>\d+\.\d+\.\d+)/)).to eq("7.4.33")
end

it "returns nil if the regex does not match" do
expect(described_class.capture_version(output, /Unknown version (?<version>\d+\.\d+\.\d+)/)).to be_nil
end
end

describe ".capture_platform_php" do
let(:parsed_composer_json) do
{
"config" => {
"platform" => {
"php" => "7.4.33"
}
}
}
end

it "captures the PHP version from the composer.json config" do
expect(described_class.capture_platform_php(parsed_composer_json)).to eq("7.4.33")
end

it "returns nil if the platform key is not present" do
expect(described_class.capture_platform_php({})).to be_nil
end
end

describe ".capture_platform" do
let(:parsed_composer_json) do
{
"config" => {
"platform" => {
"ext-json" => "1.5.0"
}
}
}
end

it "captures the platform extension version from composer.json" do
expect(described_class.capture_platform(parsed_composer_json, "ext-json")).to eq("1.5.0")
end

it "returns nil if the platform or extension name is not present" do
expect(described_class.capture_platform({}, "ext-json")).to be_nil
end
end

describe ".php_constraint" do
let(:parsed_composer_json) do
{
"require" => {
"php" => ">=7.4 <8.0"
}
}
end

it "captures the PHP version constraint from composer.json" do
expect(described_class.php_constraint(parsed_composer_json)).to eq(">=7.4 <8.0")
end

it "returns nil if the PHP constraint is not specified" do
expect(described_class.php_constraint({})).to be_nil
end
end

describe ".dependency_constraint" do
let(:parsed_composer_json) do
{
"require" => {
"ext-json" => ">=1.5.0",
"php" => ">=7.4 <8.0"
}
}
end

it "captures the version constraint for the given dependency" do
expect(described_class.dependency_constraint(parsed_composer_json, "ext-json")).to eq(">=1.5.0")
end

it "returns nil if the dependency is not specified" do
expect(described_class.dependency_constraint(parsed_composer_json, "ext-mbstring")).to be_nil
end
end
end
Loading
Loading