dependabot-common 0.384.0 → 0.386.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,138 @@
1
+ # typed: strong
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+
6
+ require "dependabot/errors"
7
+ require "dependabot/pull_request_creator"
8
+
9
+ module Dependabot
10
+ class PullRequestCreator
11
+ class BranchNameTemplate
12
+ extend T::Sig
13
+
14
+ class Error < Dependabot::DependabotError; end
15
+
16
+ SOLO_PLACEHOLDERS = T.let(
17
+ %w(prefix package_manager directory target_branch dependency version name).freeze,
18
+ T::Array[String]
19
+ )
20
+ GROUP_PLACEHOLDERS = T.let(
21
+ %w(prefix package_manager directory target_branch group_name name).freeze,
22
+ T::Array[String]
23
+ )
24
+ MULTI_ECO_PLACEHOLDERS = T.let(
25
+ %w(prefix target_branch group_name name).freeze,
26
+ T::Array[String]
27
+ )
28
+
29
+ # ---------------------------------------------------------------
30
+ # 1. validate_template(template, strategy:)
31
+ # Ensures every {token} is allowed for the given strategy,
32
+ # brackets are well-formed, and no forbidden placeholders used.
33
+ # ---------------------------------------------------------------
34
+ sig { params(template: String, strategy: Symbol).returns(T::Boolean) }
35
+ def self.validate_template(template, strategy:) # rubocop:disable Naming/PredicateMethod
36
+ raise Error, "Template must be a non-empty string." if template.empty?
37
+
38
+ allowed = allowed_placeholders(strategy)
39
+ messages = collect_template_errors(template, allowed, strategy)
40
+
41
+ raise Error, "#{messages.join("\n")}\nAllowed: #{allowed.map { |v| "{#{v}}" }.join(', ')}" if messages.any?
42
+
43
+ true
44
+ end
45
+
46
+ sig { params(strategy: Symbol).returns(T::Array[String]) }
47
+ def self.allowed_placeholders(strategy)
48
+ case strategy
49
+ when :solo then SOLO_PLACEHOLDERS
50
+ when :group then GROUP_PLACEHOLDERS
51
+ when :multi_ecosystem then MULTI_ECO_PLACEHOLDERS
52
+ else
53
+ raise Error, "Unknown strategy: #{strategy}"
54
+ end
55
+ end
56
+
57
+ sig { params(template: String, allowed: T::Array[String], strategy: Symbol).returns(T::Array[String]) }
58
+ def self.collect_template_errors(template, allowed, strategy)
59
+ used = template.scan(/\{(\w+)\}/).flatten
60
+ unknown = used.uniq.reject { |t| allowed.include?(t) }
61
+ unknown -= ["package_manager"] if strategy == :multi_ecosystem && used.include?("package_manager")
62
+ malformed = template.gsub(/\{\w+\}/, "").match?(/[{}]/)
63
+
64
+ messages = T.let([], T::Array[String])
65
+ messages << "Unknown placeholder(s): #{unknown.map { |v| "{#{v}}" }.join(', ')}" unless unknown.empty?
66
+ messages << "Malformed or unclosed braces detected." if malformed
67
+
68
+ if strategy == :multi_ecosystem && used.include?("package_manager")
69
+ messages << "{package_manager} is not available for multi-ecosystem groups (spans multiple ecosystems)."
70
+ end
71
+
72
+ messages
73
+ end
74
+
75
+ # ---------------------------------------------------------------
76
+ # 2. validate_ref_name(name)
77
+ # Validates the final branch name against Git ref rules.
78
+ # ---------------------------------------------------------------
79
+ sig { params(name: String).returns(T::Boolean) }
80
+ def self.validate_ref_name(name) # rubocop:disable Naming/PredicateMethod
81
+ illegal = %r{
82
+ [\x00-\x1F\x7F\ ~^:?*\[\\] |
83
+ \.\. |
84
+ @\{ |
85
+ // |
86
+ /\. |
87
+ \A/ |
88
+ /\z |
89
+ \.lock\z |
90
+ \A- |
91
+ \.\z |
92
+ \A@\z
93
+ }x
94
+
95
+ raise Error, "Resolved branch name \"#{name}\" is not a valid Git ref." if name.match?(illegal)
96
+
97
+ true
98
+ end
99
+
100
+ # ---------------------------------------------------------------
101
+ # 3. render(template, vars, strategy:, digest: nil)
102
+ # Full pipeline: validate template -> substitute -> append digest
103
+ # -> validate ref.
104
+ # ---------------------------------------------------------------
105
+ sig do
106
+ params(
107
+ template: String,
108
+ vars: T::Hash[String, String],
109
+ strategy: Symbol,
110
+ digest: T.nilable(String)
111
+ ).returns(String)
112
+ end
113
+ def self.render(template, vars, strategy:, digest: nil)
114
+ validate_template(template, strategy: strategy)
115
+
116
+ # Substitute placeholders
117
+ name = template.gsub(/\{(\w+)\}/) do
118
+ key = T.must(Regexp.last_match(1))
119
+ raise Error, "Missing value for placeholder \"{#{key}}\"." unless vars.key?(key)
120
+
121
+ T.must(vars[key])
122
+ end
123
+
124
+ # Auto-append digest for Group/MultiEcosystem
125
+ name = "#{name}-#{digest}" if digest && strategy != :solo
126
+
127
+ # Collapse empty path segments (e.g. {target_branch} when nil) and strip edge slashes
128
+ name = name.squeeze("/").gsub(%r{\A/|/\z}, "")
129
+ raise Error, "Resolved branch name is empty." if name.empty?
130
+
131
+ # Validate git ref after all transformations
132
+ validate_ref_name(name)
133
+
134
+ name
135
+ end
136
+ end
137
+ end
138
+ end
@@ -4,6 +4,8 @@
4
4
  require "digest"
5
5
  require "sorbet-runtime"
6
6
 
7
+ require "dependabot/pull_request_creator/branch_name_template"
8
+
7
9
  module Dependabot
8
10
  class PullRequestCreator
9
11
  class BranchNamer
@@ -28,6 +30,15 @@ module Dependabot
28
30
  sig { returns(T.nilable(Integer)) }
29
31
  attr_reader :max_length
30
32
 
33
+ sig { returns(T.nilable(String)) }
34
+ attr_reader :word_separator
35
+
36
+ sig { returns(T.nilable(String)) }
37
+ attr_reader :branch_name_case
38
+
39
+ sig { returns(T.nilable(String)) }
40
+ attr_reader :template
41
+
31
42
  sig do
32
43
  params(
33
44
  dependencies: T::Array[Dependency],
@@ -35,7 +46,10 @@ module Dependabot
35
46
  target_branch: T.nilable(String),
36
47
  separator: String,
37
48
  prefix: String,
38
- max_length: T.nilable(Integer)
49
+ max_length: T.nilable(Integer),
50
+ word_separator: T.nilable(String),
51
+ branch_name_case: T.nilable(String),
52
+ template: T.nilable(String)
39
53
  )
40
54
  .void
41
55
  end
@@ -45,7 +59,10 @@ module Dependabot
45
59
  target_branch:,
46
60
  separator: "/",
47
61
  prefix: "dependabot",
48
- max_length: nil
62
+ max_length: nil,
63
+ word_separator: nil,
64
+ branch_name_case: nil,
65
+ template: nil
49
66
  )
50
67
  @dependencies = dependencies
51
68
  @files = files
@@ -53,6 +70,9 @@ module Dependabot
53
70
  @separator = separator
54
71
  @prefix = prefix
55
72
  @max_length = max_length
73
+ @word_separator = word_separator
74
+ @branch_name_case = branch_name_case
75
+ @template = template
56
76
  end
57
77
 
58
78
  sig { overridable.returns(String) }
@@ -62,6 +82,25 @@ module Dependabot
62
82
 
63
83
  private
64
84
 
85
+ sig do
86
+ params(
87
+ vars: T::Hash[String, String],
88
+ strategy: Symbol,
89
+ digest: T.nilable(String)
90
+ ).returns(String)
91
+ end
92
+ def render_from_template(vars:, strategy:, digest: nil)
93
+ rendered = BranchNameTemplate.render(
94
+ T.must(template),
95
+ vars,
96
+ strategy: strategy,
97
+ digest: digest
98
+ )
99
+
100
+ # Apply post-processing (separator, word_separator, case) and max-length
101
+ sanitize_branch_name(rendered)
102
+ end
103
+
65
104
  sig { params(ref_name: String).returns(String) }
66
105
  def sanitize_branch_name(ref_name)
67
106
  # General git ref validation
@@ -70,6 +109,27 @@ module Dependabot
70
109
  # Some users need branch names without slashes
71
110
  sanitized_name = sanitized_name.gsub("/", separator)
72
111
 
112
+ # Apply word_separator and case transformation only to content after the prefix,
113
+ # preserving the user-configured prefix as-is.
114
+ if word_separator || branch_name_case
115
+ prefix_with_sep = "#{prefix}#{separator}"
116
+ prefix_part = sanitized_name.start_with?(prefix_with_sep) ? prefix_with_sep : ""
117
+ content = sanitized_name.delete_prefix(prefix_with_sep)
118
+
119
+ # Replace underscores with word_separator in the content after prefix
120
+ content = content.gsub("_", T.must(word_separator)) if word_separator
121
+
122
+ # Apply case transformation to content after prefix
123
+ case branch_name_case
124
+ when "lower"
125
+ content = content.downcase
126
+ when "upper"
127
+ content = content.upcase
128
+ end
129
+
130
+ sanitized_name = "#{prefix_part}#{content}"
131
+ end
132
+
73
133
  # Shorten the ref in case users refs have length limits
74
134
  branch_name_max_length = max_length
75
135
  if branch_name_max_length && (sanitized_name.length > branch_name_max_length)
@@ -19,7 +19,10 @@ module Dependabot
19
19
  includes_security_fixes: T::Boolean,
20
20
  separator: String,
21
21
  prefix: String,
22
- max_length: T.nilable(Integer)
22
+ max_length: T.nilable(Integer),
23
+ word_separator: T.nilable(String),
24
+ branch_name_case: T.nilable(String),
25
+ template: T.nilable(String)
23
26
  )
24
27
  .void
25
28
  end
@@ -31,7 +34,10 @@ module Dependabot
31
34
  includes_security_fixes:,
32
35
  separator: "/",
33
36
  prefix: "dependabot",
34
- max_length: nil
37
+ max_length: nil,
38
+ word_separator: nil,
39
+ branch_name_case: nil,
40
+ template: nil
35
41
  )
36
42
  super(
37
43
  dependencies: dependencies,
@@ -40,6 +46,9 @@ module Dependabot
40
46
  separator: separator,
41
47
  prefix: prefix,
42
48
  max_length: max_length,
49
+ word_separator: word_separator,
50
+ branch_name_case: branch_name_case,
51
+ template: template,
43
52
  )
44
53
 
45
54
  @dependency_group = dependency_group
@@ -48,6 +57,14 @@ module Dependabot
48
57
 
49
58
  sig { override.returns(String) }
50
59
  def new_branch_name
60
+ if template
61
+ return render_from_template(
62
+ vars: template_vars,
63
+ strategy: :group,
64
+ digest: dependency_digest
65
+ )
66
+ end
67
+
51
68
  sanitize_branch_name(File.join(prefixes, group_name_with_dependency_digest))
52
69
  end
53
70
 
@@ -56,6 +73,24 @@ module Dependabot
56
73
  sig { returns(Dependabot::DependencyGroup) }
57
74
  attr_reader :dependency_group
58
75
 
76
+ sig { returns(T::Hash[String, String]) }
77
+ def template_vars
78
+ directory_part = (directory || "/").sub(%r{^/}, "")
79
+ directory_part = "root" if directory_part.empty?
80
+
81
+ group_name = sanitize_ref(dependency_group.name.tr(" ", "-"))
82
+
83
+ vars = {
84
+ "prefix" => prefix,
85
+ "package_manager" => package_manager,
86
+ "directory" => directory_part,
87
+ "group_name" => group_name,
88
+ "name" => group_name,
89
+ "target_branch" => target_branch || ""
90
+ }
91
+ vars
92
+ end
93
+
59
94
  sig { returns(T::Array[String]) }
60
95
  def prefixes
61
96
  [
@@ -19,7 +19,10 @@ module Dependabot
19
19
  multi_ecosystem_name: String,
20
20
  separator: String,
21
21
  prefix: String,
22
- max_length: T.nilable(Integer)
22
+ max_length: T.nilable(Integer),
23
+ word_separator: T.nilable(String),
24
+ branch_name_case: T.nilable(String),
25
+ template: T.nilable(String)
23
26
  )
24
27
  .void
25
28
  end
@@ -31,7 +34,10 @@ module Dependabot
31
34
  multi_ecosystem_name:,
32
35
  separator: "/",
33
36
  prefix: "dependabot",
34
- max_length: nil
37
+ max_length: nil,
38
+ word_separator: nil,
39
+ branch_name_case: nil,
40
+ template: nil
35
41
  )
36
42
  super(
37
43
  dependencies: dependencies,
@@ -40,6 +46,9 @@ module Dependabot
40
46
  separator: separator,
41
47
  prefix: prefix,
42
48
  max_length: max_length,
49
+ word_separator: word_separator,
50
+ branch_name_case: branch_name_case,
51
+ template: template,
43
52
  )
44
53
 
45
54
  @multi_ecosystem_name = multi_ecosystem_name
@@ -48,6 +57,14 @@ module Dependabot
48
57
 
49
58
  sig { override.returns(String) }
50
59
  def new_branch_name
60
+ if template
61
+ return render_from_template(
62
+ vars: template_vars,
63
+ strategy: :multi_ecosystem,
64
+ digest: dependency_digest
65
+ )
66
+ end
67
+
51
68
  sanitize_branch_name(File.join(prefixes, group_name_with_dependency_digest))
52
69
  end
53
70
 
@@ -56,6 +73,19 @@ module Dependabot
56
73
  sig { returns(String) }
57
74
  attr_reader :multi_ecosystem_name
58
75
 
76
+ sig { returns(T::Hash[String, String]) }
77
+ def template_vars
78
+ multi_name = sanitize_ref(multi_ecosystem_name.tr(" ", "-"))
79
+
80
+ vars = {
81
+ "prefix" => prefix,
82
+ "group_name" => multi_name,
83
+ "name" => multi_name,
84
+ "target_branch" => target_branch || ""
85
+ }
86
+ vars
87
+ end
88
+
59
89
  sig { returns(T::Array[String]) }
60
90
  def prefixes
61
91
  [
@@ -15,26 +15,18 @@ module Dependabot
15
15
 
16
16
  sig { override.returns(String) }
17
17
  def new_branch_name
18
+ if template
19
+ return render_from_template(
20
+ vars: template_vars,
21
+ strategy: :solo
22
+ )
23
+ end
24
+
18
25
  return short_branch_name if branch_name_might_be_long?
19
26
 
20
27
  @name ||=
21
28
  T.let(
22
- begin
23
- dependency_name_part =
24
- if dependencies.count > 1 && updating_a_property?
25
- property_name
26
- elsif dependencies.count > 1 && updating_a_dependency_set?
27
- dependency_set.fetch(:group)
28
- else
29
- dependencies
30
- .map(&:name)
31
- .join("-and-")
32
- .tr(":[]", "-")
33
- .tr("@", "")
34
- end
35
-
36
- "#{dependency_name_part}-#{branch_version_suffix}"
37
- end,
29
+ "#{template_dependency_name}-#{branch_version_suffix}",
38
30
  T.nilable(String)
39
31
  )
40
32
 
@@ -53,6 +45,40 @@ module Dependabot
53
45
  ].compact
54
46
  end
55
47
 
48
+ sig { returns(T::Hash[String, String]) }
49
+ def template_vars
50
+ dep_name = template_dependency_name
51
+ version = branch_version_suffix || ""
52
+
53
+ vars = {
54
+ "prefix" => prefix,
55
+ "package_manager" => package_manager,
56
+ "directory" => sanitized_directory,
57
+ "dependency" => dep_name,
58
+ "version" => version,
59
+ "name" => "#{dep_name}-#{version}",
60
+ "target_branch" => target_branch || ""
61
+ }
62
+ vars
63
+ end
64
+
65
+ sig { returns(String) }
66
+ def template_dependency_name
67
+ if dependencies.count > 1 && updating_a_property?
68
+ property_name
69
+ elsif dependencies.count > 1 && updating_a_dependency_set?
70
+ dependency_set.fetch(:group)
71
+ else
72
+ dependencies.map(&:name).join("-and-").tr(":[]", "-").tr("@", "")
73
+ end
74
+ end
75
+
76
+ sig { returns(String) }
77
+ def sanitized_directory
78
+ dir = (files.first&.directory&.tr(" ", "-") || "/").sub(%r{^/}, "")
79
+ dir.empty? ? "root" : dir
80
+ end
81
+
56
82
  sig { returns(String) }
57
83
  def package_manager
58
84
  T.must(dependencies.first).package_manager
@@ -9,6 +9,7 @@ require "dependabot/pull_request_creator"
9
9
  require "dependabot/pull_request_creator/branch_namer/solo_strategy"
10
10
  require "dependabot/pull_request_creator/branch_namer/dependency_group_strategy"
11
11
  require "dependabot/pull_request_creator/branch_namer/multi_ecosystem_strategy"
12
+ require "dependabot/pull_request_creator/branch_name_template"
12
13
 
13
14
  module Dependabot
14
15
  class PullRequestCreator
@@ -33,6 +34,15 @@ module Dependabot
33
34
  sig { returns(T.nilable(Integer)) }
34
35
  attr_reader :max_length
35
36
 
37
+ sig { returns(T.nilable(String)) }
38
+ attr_reader :word_separator
39
+
40
+ sig { returns(T.nilable(String)) }
41
+ attr_reader :branch_name_case
42
+
43
+ sig { returns(T.nilable(String)) }
44
+ attr_reader :template
45
+
36
46
  sig { returns(T.nilable(Dependabot::DependencyGroup)) }
37
47
  attr_reader :dependency_group
38
48
 
@@ -51,6 +61,9 @@ module Dependabot
51
61
  separator: String,
52
62
  prefix: String,
53
63
  max_length: T.nilable(Integer),
64
+ word_separator: T.nilable(String),
65
+ branch_name_case: T.nilable(String),
66
+ template: T.nilable(String),
54
67
  includes_security_fixes: T::Boolean,
55
68
  multi_ecosystem_name: T.nilable(String)
56
69
  )
@@ -64,6 +77,9 @@ module Dependabot
64
77
  separator: "/",
65
78
  prefix: "dependabot",
66
79
  max_length: nil,
80
+ word_separator: nil,
81
+ branch_name_case: nil,
82
+ template: nil,
67
83
  includes_security_fixes: false,
68
84
  multi_ecosystem_name: nil
69
85
  )
@@ -74,6 +90,9 @@ module Dependabot
74
90
  @separator = separator
75
91
  @prefix = prefix
76
92
  @max_length = max_length
93
+ @word_separator = word_separator
94
+ @branch_name_case = branch_name_case
95
+ @template = template
77
96
  @includes_security_fixes = includes_security_fixes
78
97
  @multi_ecosystem_name = multi_ecosystem_name
79
98
  end
@@ -109,6 +128,9 @@ module Dependabot
109
128
  separator: separator,
110
129
  prefix: prefix,
111
130
  max_length: max_length,
131
+ word_separator: word_separator,
132
+ branch_name_case: branch_name_case,
133
+ template: template,
112
134
  multi_ecosystem_name: T.must(multi_ecosystem_name)
113
135
  )
114
136
  end
@@ -121,7 +143,10 @@ module Dependabot
121
143
  target_branch: target_branch,
122
144
  separator: separator,
123
145
  prefix: prefix,
124
- max_length: max_length
146
+ max_length: max_length,
147
+ word_separator: word_separator,
148
+ branch_name_case: branch_name_case,
149
+ template: template
125
150
  )
126
151
  end
127
152
 
@@ -135,7 +160,10 @@ module Dependabot
135
160
  includes_security_fixes: includes_security_fixes,
136
161
  separator: separator,
137
162
  prefix: prefix,
138
- max_length: max_length
163
+ max_length: max_length,
164
+ word_separator: word_separator,
165
+ branch_name_case: branch_name_case,
166
+ template: template
139
167
  )
140
168
  end
141
169
  end
@@ -45,10 +45,10 @@ module Dependabot
45
45
  sig { returns(T.nilable(String)) }
46
46
  attr_reader :pr_message_footer
47
47
 
48
- sig { returns(T.nilable(T::Hash[Symbol, T.untyped])) }
48
+ sig { returns(T.nilable(T::Hash[Symbol, T.anything])) }
49
49
  attr_reader :commit_message_options
50
50
 
51
- sig { returns(T::Hash[String, T.untyped]) }
51
+ sig { returns(T::Hash[String, T::Array[T::Hash[String, String]]]) }
52
52
  attr_reader :vulnerabilities_fixed
53
53
 
54
54
  sig { returns(T.nilable(String)) }
@@ -79,8 +79,8 @@ module Dependabot
79
79
  credentials: T::Array[Dependabot::Credential],
80
80
  pr_message_header: T.nilable(String),
81
81
  pr_message_footer: T.nilable(String),
82
- commit_message_options: T.nilable(T::Hash[Symbol, T.untyped]),
83
- vulnerabilities_fixed: T::Hash[String, T.untyped],
82
+ commit_message_options: T.nilable(T::Hash[Symbol, T.anything]),
83
+ vulnerabilities_fixed: T::Hash[String, T::Array[T::Hash[String, String]]],
84
84
  github_redirection_service: T.nilable(String),
85
85
  dependency_group: T.nilable(Dependabot::DependencyGroup),
86
86
  pr_message_max_length: T.nilable(Integer),
@@ -379,7 +379,7 @@ module Dependabot
379
379
 
380
380
  sig { returns(T.nilable(String)) }
381
381
  def custom_trailers
382
- trailers = commit_message_options&.dig(:trailers)
382
+ trailers = T.cast(commit_message_options&.dig(:trailers), T.nilable(Object))
383
383
  return if trailers.nil?
384
384
  raise("Commit trailers must be a Hash object") unless trailers.is_a?(Hash)
385
385
 
@@ -395,7 +395,7 @@ module Dependabot
395
395
 
396
396
  sig { returns(T.nilable(String)) }
397
397
  def signoff_message
398
- signoff_details = commit_message_options&.dig(:signoff_details)
398
+ signoff_details = T.cast(commit_message_options&.dig(:signoff_details), T.nilable(Object))
399
399
  return unless signoff_details.is_a?(Hash)
400
400
  return unless signoff_details[:name] && signoff_details[:email]
401
401
 
@@ -404,7 +404,7 @@ module Dependabot
404
404
 
405
405
  sig { returns(T.nilable(String)) }
406
406
  def on_behalf_of_message
407
- signoff_details = commit_message_options&.dig(:signoff_details)
407
+ signoff_details = T.cast(commit_message_options&.dig(:signoff_details), T.nilable(Object))
408
408
  return unless signoff_details.is_a?(Hash)
409
409
  return unless signoff_details[:org_name] && signoff_details[:org_email]
410
410