From c44f7c3301263d538a034b31460b4e4d3f95cf6d Mon Sep 17 00:00:00 2001 From: Kamil Bukum Date: Wed, 4 Mar 2026 13:21:39 -0600 Subject: [PATCH 1/3] Add TitleBuilder class for composing PR titles with prefixes --- .../pull_request_creator/message_builder.rb | 7 +- .../message_builder/title_builder.rb | 125 ++++++++++ .../message_builder/title_builder_spec.rb | 230 ++++++++++++++++++ 3 files changed, 360 insertions(+), 2 deletions(-) create mode 100644 common/lib/dependabot/pull_request_creator/message_builder/title_builder.rb create mode 100644 common/spec/dependabot/pull_request_creator/message_builder/title_builder_spec.rb diff --git a/common/lib/dependabot/pull_request_creator/message_builder.rb b/common/lib/dependabot/pull_request_creator/message_builder.rb index 8defd65b860..a781ccafcfd 100644 --- a/common/lib/dependabot/pull_request_creator/message_builder.rb +++ b/common/lib/dependabot/pull_request_creator/message_builder.rb @@ -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) } @@ -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) } diff --git a/common/lib/dependabot/pull_request_creator/message_builder/title_builder.rb b/common/lib/dependabot/pull_request_creator/message_builder/title_builder.rb new file mode 100644 index 00000000000..054946b1652 --- /dev/null +++ b/common/lib/dependabot/pull_request_creator/message_builder/title_builder.rb @@ -0,0 +1,125 @@ +# typed: strict +# frozen_string_literal: true + +require "sorbet-runtime" +require "dependabot/dependency" +require "dependabot/logger" + +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' if 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 + rescue StandardError + true + end + + sig { returns(String) } + def scope + production_dependencies? ? "deps" : "deps-dev" + end + end + end + end +end diff --git a/common/spec/dependabot/pull_request_creator/message_builder/title_builder_spec.rb b/common/spec/dependabot/pull_request_creator/message_builder/title_builder_spec.rb new file mode 100644 index 00000000000..ecf57f098ff --- /dev/null +++ b/common/spec/dependabot/pull_request_creator/message_builder/title_builder_spec.rb @@ -0,0 +1,230 @@ +# typed: false +# frozen_string_literal: true + +require "spec_helper" +require "dependabot/pull_request_creator/message_builder/title_builder" +require "dependabot/pull_request_creator/pr_name_prefixer" + +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 + + 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 From 2c2752c876aa58aaefb02aaf6e5f1572b0804691 Mon Sep 17 00:00:00 2001 From: Kamil Bukum Date: Wed, 4 Mar 2026 13:45:36 -0600 Subject: [PATCH 2/3] Require PrNamePrefixer in TitleBuilder and its spec file --- .../pull_request_creator/message_builder/title_builder.rb | 1 + .../pull_request_creator/message_builder/title_builder_spec.rb | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/common/lib/dependabot/pull_request_creator/message_builder/title_builder.rb b/common/lib/dependabot/pull_request_creator/message_builder/title_builder.rb index 054946b1652..f66498de06b 100644 --- a/common/lib/dependabot/pull_request_creator/message_builder/title_builder.rb +++ b/common/lib/dependabot/pull_request_creator/message_builder/title_builder.rb @@ -4,6 +4,7 @@ require "sorbet-runtime" require "dependabot/dependency" require "dependabot/logger" +require "dependabot/pull_request_creator/pr_name_prefixer" module Dependabot class PullRequestCreator diff --git a/common/spec/dependabot/pull_request_creator/message_builder/title_builder_spec.rb b/common/spec/dependabot/pull_request_creator/message_builder/title_builder_spec.rb index ecf57f098ff..4a582a72ef0 100644 --- a/common/spec/dependabot/pull_request_creator/message_builder/title_builder_spec.rb +++ b/common/spec/dependabot/pull_request_creator/message_builder/title_builder_spec.rb @@ -2,8 +2,8 @@ # frozen_string_literal: true require "spec_helper" -require "dependabot/pull_request_creator/message_builder/title_builder" 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 From b6a735971195e97fe2742258c1a7c6cc1413aa24 Mon Sep 17 00:00:00 2001 From: Kamil Bukum Date: Wed, 4 Mar 2026 14:59:35 -0600 Subject: [PATCH 3/3] Fix pluralization in multi_ecosystem_base_title method --- .../pull_request_creator/message_builder/title_builder.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/lib/dependabot/pull_request_creator/message_builder/title_builder.rb b/common/lib/dependabot/pull_request_creator/message_builder/title_builder.rb index f66498de06b..245aa9618f9 100644 --- a/common/lib/dependabot/pull_request_creator/message_builder/title_builder.rb +++ b/common/lib/dependabot/pull_request_creator/message_builder/title_builder.rb @@ -50,7 +50,7 @@ def initialize(base_title:, prefixer: nil, commit_message_options: nil, dependen 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' if update_count > 1} across multiple ecosystems" + "#{update_count} update#{'s' unless update_count == 1} across multiple ecosystems" end sig { returns(String) }