Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ class MessageBuilder
require_relative "message_builder/metadata_presenter"
require_relative "message_builder/issue_linker"
require_relative "message_builder/link_and_mention_sanitizer"
require_relative "message_builder/title_builder"
require_relative "pr_name_prefixer"

sig { returns(Dependabot::Source) }
Expand Down Expand Up @@ -130,8 +131,10 @@ def initialize(
sig { returns(String) }
def pr_name
name = dependency_group ? group_pr_name : solo_pr_name
name[0] = T.must(name[0]).capitalize if pr_name_prefixer.capitalize_first_word?
"#{pr_name_prefix}#{name}"
MessageBuilder::TitleBuilder.new(
base_title: name,
prefixer: pr_name_prefixer
).build
end

sig { returns(String) }
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
# typed: strict
# frozen_string_literal: true

require "sorbet-runtime"
require "dependabot/dependency"
require "dependabot/logger"
require "dependabot/pull_request_creator/pr_name_prefixer"

module Dependabot
class PullRequestCreator
class MessageBuilder
# Composes a final PR title from a base title + prefix.
#
# Works in two modes:
# 1. With a full PrNamePrefixer (updater path — has source/credentials for
# commit style auto-detection)
# 2. With just commit_message_options (API path — explicit prefix only,
# no network calls needed)
class TitleBuilder
extend T::Sig

sig { returns(String) }
attr_reader :base_title

sig { returns(T.nilable(Dependabot::PullRequestCreator::PrNamePrefixer)) }
attr_reader :prefixer

sig { returns(T.nilable(T::Hash[Symbol, T.untyped])) }
attr_reader :commit_message_options

sig { returns(T.nilable(T::Array[Dependabot::Dependency])) }
attr_reader :dependencies

sig do
params(
base_title: String,
prefixer: T.nilable(Dependabot::PullRequestCreator::PrNamePrefixer),
commit_message_options: T.nilable(T::Hash[Symbol, T.untyped]),
dependencies: T.nilable(T::Array[Dependabot::Dependency])
).void
end
def initialize(base_title:, prefixer: nil, commit_message_options: nil, dependencies: nil)
@base_title = base_title
@prefixer = prefixer
@commit_message_options = commit_message_options
@dependencies = dependencies
end

# Generates a base title for multi-ecosystem combined PR updates.
sig { params(group_name: String, update_count: Integer).returns(String) }
def self.multi_ecosystem_base_title(group_name:, update_count:)
"bump the \"#{group_name}\" group with " \
"#{update_count} update#{'s' unless update_count == 1} across multiple ecosystems"
end

sig { returns(String) }
def build
name = base_title.dup
name[0] = T.must(name[0]).capitalize if capitalize?
"#{prefix}#{name}"
end

private

sig { returns(String) }
def prefix
return T.must(prefixer).pr_name_prefix if prefixer

build_explicit_prefix
rescue StandardError => e
Dependabot.logger.error("Error while generating PR name prefix: #{e.message}")
Dependabot.logger.error(e.backtrace&.join("\n"))
""
end

sig { returns(T::Boolean) }
def capitalize?
return T.must(prefixer).capitalize_first_word? if prefixer

false
end

# Builds prefix from explicit commit_message_options only.
# Same logic as PrNamePrefixer#prefix_from_explicitly_provided_details
# but without requiring source/credentials.
sig { returns(String) }
def build_explicit_prefix
return "" unless commit_message_options&.key?(:prefix)

prefix = explicit_prefix_string
return "" if prefix.empty?

prefix += "(#{scope})" if commit_message_options&.dig(:include_scope)
# Append colon after alphanumeric or closing bracket to follow
# conventional commit format (e.g., "chore: ..." or "fix(deps): ...")
prefix += ":" if prefix.match?(/[A-Za-z0-9\)\]]\Z/)
prefix += " " unless prefix.end_with?(" ")
prefix
end

sig { returns(String) }
def explicit_prefix_string
if production_dependencies?
commit_message_options&.dig(:prefix).to_s
elsif commit_message_options&.key?(:prefix_development)
commit_message_options&.dig(:prefix_development).to_s
else
commit_message_options&.dig(:prefix).to_s
end
end

sig { returns(T::Boolean) }
def production_dependencies?
dependencies&.any?(&:production?) != false
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

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

production_dependencies? treats an empty dependencies array as non-production because [].any? is false (so the method returns false). Given dependencies is optional on this helper, callers may reasonably pass [] to mean “unknown/no deps”; in that case we likely want to default to production (or at least keep behavior consistent with nil, which currently defaults to production). Consider changing this to treat nil/empty as production (e.g., return true unless dependencies&.all? { |d| !d.production? }).

Suggested change
dependencies&.any?(&:production?) != false
return true if dependencies.nil? || dependencies.empty?
dependencies.any?(&:production?)

Copilot uses AI. Check for mistakes.
rescue StandardError
true
end

sig { returns(String) }
def scope
production_dependencies? ? "deps" : "deps-dev"
end
end
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
# typed: false
# frozen_string_literal: true

require "spec_helper"
require "dependabot/pull_request_creator/pr_name_prefixer"
require "dependabot/pull_request_creator/message_builder/title_builder"

RSpec.describe Dependabot::PullRequestCreator::MessageBuilder::TitleBuilder do
before do
Dependabot::Dependency.register_production_check(
"npm_and_yarn",
lambda do |groups|
return true if groups.empty?
return true if groups.include?("optionalDependencies")

groups.include?("dependencies")
end
)
end

Comment on lines +5 to +20
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

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

This spec registers a global production check for npm_and_yarn, which mutates Dependabot::Dependency state for the rest of the test suite (order-dependent) and duplicates the canonical lambda already defined in npm_and_yarn/lib/dependabot/npm_and_yarn.rb. Prefer requiring dependabot/npm_and_yarn to use the real registration, or avoid global registration by stubbing Dependency#production? / using test doubles for dependencies in these examples.

Suggested change
require "dependabot/pull_request_creator/pr_name_prefixer"
require "dependabot/pull_request_creator/message_builder/title_builder"
RSpec.describe Dependabot::PullRequestCreator::MessageBuilder::TitleBuilder do
before do
Dependabot::Dependency.register_production_check(
"npm_and_yarn",
lambda do |groups|
return true if groups.empty?
return true if groups.include?("optionalDependencies")
groups.include?("dependencies")
end
)
end
require "dependabot/npm_and_yarn"
require "dependabot/pull_request_creator/pr_name_prefixer"
require "dependabot/pull_request_creator/message_builder/title_builder"
RSpec.describe Dependabot::PullRequestCreator::MessageBuilder::TitleBuilder do

Copilot uses AI. Check for mistakes.
describe "#build" do
context "with no prefix" do
subject(:builder) do
described_class.new(base_title: "bump lodash from 4.0.0 to 5.0.0")
end

it "returns the base title unchanged" do
expect(builder.build).to eq("bump lodash from 4.0.0 to 5.0.0")
end
end

context "with explicit commit_message_options prefix" do
subject(:builder) do
described_class.new(
base_title: "bump lodash from 4.0.0 to 5.0.0",
commit_message_options: { prefix: "[ci]" },
dependencies: dependencies
)
end

let(:dependencies) do
[
Dependabot::Dependency.new(
name: "lodash",
version: "5.0.0",
previous_version: "4.0.0",
package_manager: "npm_and_yarn",
requirements: [{ file: "package.json", requirement: "^5.0.0", groups: [], source: nil }],
previous_requirements: [{ file: "package.json", requirement: "^4.0.0", groups: [], source: nil }]
)
]
end

it "applies the prefix" do
expect(builder.build).to eq("[ci]: bump lodash from 4.0.0 to 5.0.0")
end
end

context "with prefix ending in space" do
subject(:builder) do
described_class.new(
base_title: "bump lodash from 4.0.0 to 5.0.0",
commit_message_options: { prefix: "[ci] " },
dependencies: dependencies
)
end

let(:dependencies) do
[
Dependabot::Dependency.new(
name: "lodash",
version: "5.0.0",
previous_version: "4.0.0",
package_manager: "npm_and_yarn",
requirements: [{ file: "package.json", requirement: "^5.0.0", groups: [], source: nil }],
previous_requirements: [{ file: "package.json", requirement: "^4.0.0", groups: [], source: nil }]
)
]
end

it "does not double-space" do
expect(builder.build).to eq("[ci] bump lodash from 4.0.0 to 5.0.0")
end
end

context "with include_scope option" do
subject(:builder) do
described_class.new(
base_title: "bump lodash from 4.0.0 to 5.0.0",
commit_message_options: { prefix: "chore", include_scope: true },
dependencies: dependencies
)
end

let(:dependencies) do
[
Dependabot::Dependency.new(
name: "lodash",
version: "5.0.0",
previous_version: "4.0.0",
package_manager: "npm_and_yarn",
requirements: [{
file: "package.json",
requirement: "^5.0.0",
groups: ["dependencies"],
source: nil
}],
previous_requirements: [{
file: "package.json",
requirement: "^4.0.0",
groups: ["dependencies"],
source: nil
}]
)
]
end

it "includes scope in the prefix" do
expect(builder.build).to eq("chore(deps): bump lodash from 4.0.0 to 5.0.0")
end
end

context "with prefix_development for dev dependency" do
subject(:builder) do
described_class.new(
base_title: "bump eslint from 7.0.0 to 8.0.0",
commit_message_options: { prefix: "fix", prefix_development: "chore" },
dependencies: dependencies
)
end

let(:dependencies) do
[
Dependabot::Dependency.new(
name: "eslint",
version: "8.0.0",
previous_version: "7.0.0",
package_manager: "npm_and_yarn",
requirements: [{
file: "package.json",
requirement: "^8.0.0",
groups: ["devDependencies"],
source: nil
}],
previous_requirements: [{
file: "package.json",
requirement: "^7.0.0",
groups: ["devDependencies"],
source: nil
}]
)
]
end

it "uses the development prefix" do
expect(builder.build).to eq("chore: bump eslint from 7.0.0 to 8.0.0")
end
end

context "with a PrNamePrefixer" do
subject(:builder) do
described_class.new(
base_title: "bump lodash from 4.0.0 to 5.0.0",
prefixer: prefixer
)
end

let(:prefixer) { instance_double(Dependabot::PullRequestCreator::PrNamePrefixer) }

before do
allow(prefixer).to receive_messages(pr_name_prefix: "⬆️ ", capitalize_first_word?: true)
end

it "uses prefixer for prefix and capitalization" do
expect(builder.build).to eq("⬆️ Bump lodash from 4.0.0 to 5.0.0")
end
end

context "with empty prefix string" do
subject(:builder) do
described_class.new(
base_title: "bump lodash from 4.0.0 to 5.0.0",
commit_message_options: { prefix: "" },
dependencies: dependencies
)
end

let(:dependencies) do
[
Dependabot::Dependency.new(
name: "lodash",
version: "5.0.0",
previous_version: "4.0.0",
package_manager: "npm_and_yarn",
requirements: [{ file: "package.json", requirement: "^5.0.0", groups: [], source: nil }],
previous_requirements: [{ file: "package.json", requirement: "^4.0.0", groups: [], source: nil }]
)
]
end

it "returns the base title without prefix" do
expect(builder.build).to eq("bump lodash from 4.0.0 to 5.0.0")
end
end
end

describe ".multi_ecosystem_base_title" do
it "returns the multi-ecosystem title with plural updates" do
expect(described_class.multi_ecosystem_base_title(group_name: "my-dependencies", update_count: 3)).to eq(
"bump the \"my-dependencies\" group with 3 updates across multiple ecosystems"
)
end

context "with a single update" do
it "returns singular update" do
expect(described_class.multi_ecosystem_base_title(group_name: "my-dependencies", update_count: 1)).to eq(
"bump the \"my-dependencies\" group with 1 update across multiple ecosystems"
)
end
end

context "with a different group name" do
it "uses the group name" do
expect(described_class.multi_ecosystem_base_title(group_name: "security-patches", update_count: 3)).to eq(
"bump the \"security-patches\" group with 3 updates across multiple ecosystems"
)
end
end
end
end
Loading