@@ -2,6 +2,7 @@ package dependabot
22
33import (
44 "bufio"
5+ "bytes"
56 "encoding/json"
67 "errors"
78 "fmt"
@@ -22,11 +23,48 @@ type Ecosystem struct {
2223 PackageEcosystem string
2324 Directory string
2425 Interval string
26+ CoolDown * CoolDown `json:",omitempty"`
27+ Groups map [string ]Group `json:",omitempty"`
2528}
2629
2730type UpdateDependabotConfigRequest struct {
28- Ecosystems []Ecosystem
29- Content string
31+ Ecosystems []Ecosystem
32+ Content string
33+ Subtractive bool
34+ }
35+
36+ // CoolDown represents the cooldown block, which the upstream dependabot package does not support.
37+ type CoolDown struct {
38+ DefaultDays int `yaml:"default-days,omitempty"`
39+ SemverMajorDays int `yaml:"semver-major-days,omitempty"`
40+ SemverMinorDays int `yaml:"semver-minor-days,omitempty"`
41+ SemverPatchDays int `yaml:"semver-patch-days,omitempty"`
42+ Include []string `yaml:"include,omitempty"`
43+ Exclude []string `yaml:"exclude,omitempty"`
44+ }
45+
46+ // Group represents a single entry in the groups block.
47+ type Group struct {
48+ AppliesTo string `yaml:"applies-to,omitempty"`
49+ Patterns []string `yaml:"patterns,omitempty"`
50+ ExcludePatterns []string `yaml:"exclude-patterns,omitempty"`
51+ DependencyType string `yaml:"dependency-type,omitempty"`
52+ UpdateTypes []string `yaml:"update-types,omitempty"`
53+ GroupBy string `yaml:"group-by,omitempty"`
54+ }
55+
56+ // Update embeds the upstream dependabot.Update inline so all its fields are preserved,
57+ // and extends it with the groups and cooldown blocks.
58+ type ExtendedUpdate struct {
59+ dependabot.Update `yaml:",inline"`
60+ Groups map [string ]Group `yaml:"groups,omitempty"`
61+ CoolDown * CoolDown `yaml:"cooldown,omitempty"`
62+ }
63+
64+ // Config is the top-level dependabot config file structure backed by Update.
65+ type Config struct {
66+ Version int `yaml:"version"`
67+ Updates []ExtendedUpdate `yaml:"updates"`
3068}
3169
3270// getIndentation returns the indentation level of the first list found in a given YAML string.
@@ -70,8 +108,8 @@ func UpdateDependabotConfig(dependabotConfig string) (*UpdateDependabotConfigRes
70108 }
71109
72110 inputConfigFile := []byte (updateDependabotConfigRequest .Content )
73- configMetadata := dependabot . New ()
74- err = configMetadata .Unmarshal (inputConfigFile )
111+ var cfg Config
112+ err = yaml .Unmarshal (inputConfigFile , & cfg )
75113 if err != nil {
76114 return nil , fmt .Errorf ("failed to unmarshal dependabot config: %v" , err )
77115 }
@@ -83,6 +121,24 @@ func UpdateDependabotConfig(dependabotConfig string) (*UpdateDependabotConfigRes
83121 response .OriginalInput = updateDependabotConfigRequest .Content
84122 response .IsChanged = false
85123
124+ // In subtractive mode, update only the specified fields of existing entries.
125+ if updateDependabotConfigRequest .Subtractive {
126+ if updateDependabotConfigRequest .Content == "" {
127+ return response , nil
128+ }
129+ subtractiveIndent , err := getIndentation (string (inputConfigFile ))
130+ if err != nil {
131+ return nil , fmt .Errorf ("failed to get indentation: %v" , err )
132+ }
133+ newContent , changed , err := updateSubtractiveFields (response .FinalOutput , updateDependabotConfigRequest .Ecosystems , subtractiveIndent - 1 )
134+ if err != nil {
135+ return nil , fmt .Errorf ("failed to apply subtractive update: %v" , err )
136+ }
137+ response .FinalOutput = newContent
138+ response .IsChanged = changed
139+ return response , nil
140+ }
141+
86142 // Using strings.Builder for efficient string concatenation
87143 var finalOutput strings.Builder
88144 finalOutput .WriteString (response .FinalOutput )
@@ -104,20 +160,24 @@ func UpdateDependabotConfig(dependabotConfig string) (*UpdateDependabotConfigRes
104160
105161 for _ , Update := range updateDependabotConfigRequest .Ecosystems {
106162 updateAlreadyExist := false
107- for _ , update := range configMetadata .Updates {
108- if update .PackageEcosystem == Update .PackageEcosystem && update .Directory == Update .Directory {
163+ for _ , update := range cfg .Updates {
164+ if update .PackageEcosystem == Update .PackageEcosystem && ( update .Directory == Update .Directory || update . Directory == Update . Directory + "/" ) {
109165 updateAlreadyExist = true
110166 break
111167 }
112168 }
113169
114170 if ! updateAlreadyExist {
115- item := dependabot.Update {
116- PackageEcosystem : Update .PackageEcosystem ,
117- Directory : Update .Directory ,
118- Schedule : dependabot.Schedule {Interval : Update .Interval },
171+ item := ExtendedUpdate {
172+ Update : dependabot.Update {
173+ PackageEcosystem : Update .PackageEcosystem ,
174+ Directory : Update .Directory ,
175+ Schedule : dependabot.Schedule {Interval : Update .Interval },
176+ },
177+ Groups : Update .Groups ,
178+ CoolDown : Update .CoolDown ,
119179 }
120- items := []dependabot. Update {item }
180+ items := []ExtendedUpdate {item }
121181 addedItem , err := yaml .Marshal (items )
122182 if err != nil {
123183 return nil , fmt .Errorf ("failed to marshal update items: %v" , err )
@@ -138,6 +198,165 @@ func UpdateDependabotConfig(dependabotConfig string) (*UpdateDependabotConfigRes
138198 return response , nil
139199}
140200
201+ // updateSubtractiveFields finds each ecosystem entry in the existing YAML config by
202+ // PackageEcosystem + Directory, then updates only the non-empty fields from the request,
203+ func updateSubtractiveFields (content string , ecosystems []Ecosystem , indent int ) (string , bool , error ) {
204+ var cfg Config
205+ if err := yaml .Unmarshal ([]byte (content ), & cfg ); err != nil {
206+ return "" , false , fmt .Errorf ("failed to parse yaml: %w" , err )
207+ }
208+
209+ existingChanged := false
210+ var toAdd []Ecosystem
211+
212+ for _ , eco := range ecosystems {
213+ found := false
214+ for i , update := range cfg .Updates {
215+ if update .PackageEcosystem != eco .PackageEcosystem {
216+ continue
217+ }
218+ if update .Directory != eco .Directory && update .Directory != eco .Directory + "/" {
219+ continue
220+ }
221+ found = true
222+
223+ // Found the matching entry — update only non-empty fields.
224+ if eco .Interval != "" && cfg .Updates [i ].Schedule .Interval != eco .Interval {
225+ cfg .Updates [i ].Schedule .Interval = eco .Interval
226+ existingChanged = true
227+ }
228+
229+ if eco .CoolDown != nil {
230+ if cfg .Updates [i ].CoolDown == nil {
231+ cfg .Updates [i ].CoolDown = & CoolDown {}
232+ }
233+ existing := cfg .Updates [i ].CoolDown
234+ cd := eco .CoolDown
235+ if cd .DefaultDays != 0 && existing .DefaultDays != cd .DefaultDays {
236+ existing .DefaultDays = cd .DefaultDays
237+ existingChanged = true
238+ }
239+ if cd .SemverMajorDays != 0 && existing .SemverMajorDays != cd .SemverMajorDays {
240+ existing .SemverMajorDays = cd .SemverMajorDays
241+ existingChanged = true
242+ }
243+ if cd .SemverMinorDays != 0 && existing .SemverMinorDays != cd .SemverMinorDays {
244+ existing .SemverMinorDays = cd .SemverMinorDays
245+ existingChanged = true
246+ }
247+ if cd .SemverPatchDays != 0 && existing .SemverPatchDays != cd .SemverPatchDays {
248+ existing .SemverPatchDays = cd .SemverPatchDays
249+ existingChanged = true
250+ }
251+ if len (cd .Include ) > 0 {
252+ existing .Include = cd .Include
253+ existingChanged = true
254+ }
255+ if len (cd .Exclude ) > 0 {
256+ existing .Exclude = cd .Exclude
257+ existingChanged = true
258+ }
259+ }
260+
261+ if len (eco .Groups ) > 0 {
262+ if cfg .Updates [i ].Groups == nil {
263+ cfg .Updates [i ].Groups = make (map [string ]Group )
264+ }
265+ for groupName , ecoGroup := range eco .Groups {
266+ existing , exists := cfg .Updates [i ].Groups [groupName ]
267+ if ! exists {
268+ // New group — add it wholesale.
269+ cfg .Updates [i ].Groups [groupName ] = ecoGroup
270+ existingChanged = true
271+ continue
272+ }
273+ // Existing group — update only the non-empty fields.
274+ if ecoGroup .AppliesTo != "" && existing .AppliesTo != ecoGroup .AppliesTo {
275+ existing .AppliesTo = ecoGroup .AppliesTo
276+ existingChanged = true
277+ }
278+ if len (ecoGroup .Patterns ) > 0 {
279+ existing .Patterns = ecoGroup .Patterns
280+ existingChanged = true
281+ }
282+ if len (ecoGroup .ExcludePatterns ) > 0 {
283+ existing .ExcludePatterns = ecoGroup .ExcludePatterns
284+ existingChanged = true
285+ }
286+ if ecoGroup .DependencyType != "" && existing .DependencyType != ecoGroup .DependencyType {
287+ existing .DependencyType = ecoGroup .DependencyType
288+ existingChanged = true
289+ }
290+ if len (ecoGroup .UpdateTypes ) > 0 {
291+ existing .UpdateTypes = ecoGroup .UpdateTypes
292+ existingChanged = true
293+ }
294+ if ecoGroup .GroupBy != "" && existing .GroupBy != ecoGroup .GroupBy {
295+ existing .GroupBy = ecoGroup .GroupBy
296+ existingChanged = true
297+ }
298+ cfg .Updates [i ].Groups [groupName ] = existing
299+ }
300+ }
301+ break
302+ }
303+
304+ if ! found {
305+ toAdd = append (toAdd , eco )
306+ }
307+ }
308+
309+ if ! existingChanged && len (toAdd ) == 0 {
310+ return content , false , nil
311+ }
312+
313+ // Re-serialize existing entries only when they were actually modified;
314+ // otherwise preserve the original content so formatting is not disturbed.
315+ var finalOutput strings.Builder
316+ if existingChanged {
317+ var buf bytes.Buffer
318+ enc := yaml .NewEncoder (& buf )
319+ enc .SetIndent (indent )
320+ if err := enc .Encode (& cfg ); err != nil {
321+ return "" , false , fmt .Errorf ("failed to marshal yaml: %w" , err )
322+ }
323+ finalOutput .WriteString (buf .String ())
324+ } else {
325+ finalOutput .WriteString (content )
326+ }
327+
328+ // Append new ecosystems using the same additive approach: marshal each entry
329+ // individually and WriteString with addIndentation so a blank line is
330+ // inserted before every new entry.
331+ for _ , eco := range toAdd {
332+ item := ExtendedUpdate {
333+ Update : dependabot.Update {
334+ PackageEcosystem : eco .PackageEcosystem ,
335+ Directory : eco .Directory ,
336+ Schedule : dependabot.Schedule {Interval : eco .Interval },
337+ },
338+ Groups : eco .Groups ,
339+ CoolDown : eco .CoolDown ,
340+ }
341+ items := []ExtendedUpdate {item }
342+ var itemBuf bytes.Buffer
343+ itemEnc := yaml .NewEncoder (& itemBuf )
344+ itemEnc .SetIndent (indent )
345+ if err := itemEnc .Encode (items ); err != nil {
346+ return "" , false , fmt .Errorf ("failed to marshal update items: %w" , err )
347+ }
348+ // addIndentation expects a 1-indexed column; indent is already the space
349+ // count, so pass indent+1.
350+ data , err := addIndentation (itemBuf .String (), indent + 1 )
351+ if err != nil {
352+ return "" , false , fmt .Errorf ("failed to add indentation: %w" , err )
353+ }
354+ finalOutput .WriteString (data )
355+ }
356+
357+ return finalOutput .String (), true , nil
358+ }
359+
141360// addIndentation adds a certain number of spaces to the start of each line in the input string.
142361// It returns a new string with the added indentation.
143362func addIndentation (data string , indentation int ) (string , error ) {
0 commit comments